├── .drone.yml
├── .github
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── php.yml
├── .gitignore
├── .php-cs-fixer.php
├── .scrutinizer.yml
├── LICENSE
├── README.md
├── bin
└── stats
├── cache
└── .gitignore
├── composer.json
├── composer.lock
├── etc
├── config.dist.json
├── migrations
│ ├── 20160618001.sql
│ ├── 20180413001.sql
│ └── 20200313001.sql
├── mysql.sql
└── unatantum.sql
├── logs
└── .gitignore
├── phpunit.xml.dist
├── ruleset.xml
├── snapshots
└── .gitignore
├── src
├── Commands
│ ├── Database
│ │ ├── MigrateCommand.php
│ │ └── MigrationStatusCommand.php
│ ├── SnapshotCommand.php
│ ├── SnapshotRecentlyUpdatedCommand.php
│ ├── Tags
│ │ ├── AbstractTagCommand.php
│ │ ├── FetchJoomlaTagsCommand.php
│ │ └── FetchPhpTagsCommand.php
│ └── UpdateCommand.php
├── Controllers
│ ├── DisplayStatisticsController.php
│ └── SubmitDataController.php
├── Database
│ ├── Exception
│ │ ├── CannotInitializeMigrationsException.php
│ │ ├── UnknownMigrationException.php
│ │ └── UnreadableMigrationException.php
│ ├── Migrations.php
│ └── MigrationsStatus.php
├── Decorators
│ └── ValidateVersion.php
├── EventListener
│ ├── AnalyticsSubscriber.php
│ └── ErrorSubscriber.php
├── GitHub
│ ├── GitHub.php
│ ├── Package.php
│ └── Package
│ │ └── Repositories.php
├── Kernel.php
├── Kernel
│ ├── ConsoleKernel.php
│ └── WebKernel.php
├── Providers
│ ├── AnalyticsServiceProvider.php
│ ├── ConsoleServiceProvider.php
│ ├── DatabaseServiceProvider.php
│ ├── EventServiceProvider.php
│ ├── FlysystemServiceProvider.php
│ ├── GitHubServiceProvider.php
│ ├── MonologServiceProvider.php
│ ├── RepositoryServiceProvider.php
│ └── WebApplicationServiceProvider.php
├── Repositories
│ ├── InfluxdbRepository.php
│ └── StatisticsRepository.php
├── Views
│ └── Stats
│ │ └── StatsJsonView.php
└── WebApplication.php
├── tests
├── Commands
│ ├── Database
│ │ ├── MigrateCommandTest.php
│ │ └── MigrationStatusCommandTest.php
│ ├── SnapshotCommandTest.php
│ ├── SnapshotRecentlyUpdatedCommandTest.php
│ └── Tags
│ │ ├── FetchJoomlaTagsCommandTest.php
│ │ └── FetchPhpTagsCommandTest.php
├── Controllers
│ ├── DisplayStatisticsControllerTest.php
│ └── SubmitDataControllerTest.php
├── Database
│ └── MigrationsTest.php
├── DatabaseManager.php
├── DatabaseTestCase.php
├── EventListener
│ ├── AnalyticsSubscriberTest.php
│ └── ErrorSubscriberTest.php
├── Kernel
│ ├── ConsoleKernelTest.php
│ └── WebKernelTest.php
├── KernelTest.php
├── Repositories
│ └── StatisticsRepositoryTest.php
├── Views
│ └── Stats
│ │ └── StatsJsonViewTest.php
└── bootstrap.php
├── versions
├── joomla.json
└── php.json
└── www
├── .htaccess
└── index.php
/.drone.yml:
--------------------------------------------------------------------------------
1 | ---
2 | kind: pipeline
3 | name: default
4 |
5 | clone:
6 |
7 | steps:
8 | - name: composer
9 | image: joomlaprojects/docker-images:php8.1
10 | volumes:
11 | - name: composer-cache
12 | path: /tmp/composer-cache
13 | commands:
14 | - composer install --no-progress
15 |
16 | - name: phpcs
17 | image: joomlaprojects/docker-images:php8.1
18 | depends_on: [ composer ]
19 | commands:
20 | - echo $(date)
21 | - ./vendor/bin/phpcs --config-set installed_paths vendor/joomla/coding-standards
22 | - ./vendor/bin/phpcs --extensions=php -p --standard=ruleset.xml .
23 | - echo $(date)
24 |
25 | - name: php81
26 | depends_on: [ phpcs ]
27 | image: joomlaprojects/docker-images:php8.1
28 | commands:
29 | - php -v
30 | - ./vendor/bin/phpunit
31 |
32 | - name: deployment
33 | image: appleboy/drone-ssh
34 | depends_on:
35 | - php81
36 | settings:
37 | host:
38 | from_secret: stats_host
39 | username:
40 | from_secret: stats_username
41 | port: 22
42 | key:
43 | from_secret: stats_key
44 | script:
45 | - cd /home/devj/jstats-server
46 | - bin/stats update:server
47 | - chmod 644 www/index.php
48 | when:
49 | branch:
50 | - master
51 | status:
52 | - success
53 | event:
54 | - push
55 |
56 | volumes:
57 | - name: composer-cache
58 | host:
59 | path: /tmp/composer-cache
60 |
61 | services:
62 | - name: mysql
63 | image: mysql:5.7
64 | environment:
65 | MYSQL_USER: joomla_ut
66 | MYSQL_PASSWORD: joomla_ut
67 | MYSQL_ROOT_PASSWORD: joomla_ut
68 | MYSQL_DATABASE: test_joomla
69 | ---
70 | kind: signature
71 | hmac: 8be22045dcdba2900053efa460a017b12d49fa4b0e52f759424c359a503e13cf
72 |
73 | ...
74 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | #### Steps to reproduce the issue
2 |
3 |
4 |
5 | #### Expected result
6 |
7 |
8 |
9 | #### Actual result
10 |
11 |
12 |
13 | #### System information (as much as possible)
14 |
15 |
16 |
17 | #### Additional comments
18 |
19 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | Pull Request for Issue # .
2 |
3 | #### Summary of Changes
4 |
5 | #### Testing Instructions
6 |
--------------------------------------------------------------------------------
/.github/workflows/php.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: PHP CRON CI
5 |
6 | on:
7 | push:
8 | schedule:
9 | # * is a special character in YAML so you have to quote this string
10 | # Runs every day at 00:00
11 | - cron: '0 0 * * *'
12 |
13 | jobs:
14 | build:
15 |
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 | - uses: actions/checkout@v2
20 |
21 | # - name: Validate composer.json and composer.lock
22 | # run: composer validate --strict
23 |
24 | # - name: Cache Composer packages
25 | # id: composer-cache
26 | # uses: actions/cache@v2
27 | # with:
28 | # path: vendor
29 | # key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
30 | # restore-keys: |
31 | # ${{ runner.os }}-php-
32 |
33 | - name: Install dependencies
34 | run: composer install --prefer-dist --no-progress --no-suggest
35 |
36 | - name: Update PHP versions.json
37 | run: php bin/stats tags:php
38 |
39 | - uses: stefanzweifel/git-auto-commit-action@v4.9.2
40 | with:
41 | commit_message: Adding back the json created...
42 | branch: ${{ github.head_ref }}
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea
2 | /vendor
3 | /phpunit.xml
4 | /build/coverage
5 | /etc/config.json
6 | /.php_cs.cache
7 | /.phpunit.result.cache
8 |
--------------------------------------------------------------------------------
/.php-cs-fixer.php:
--------------------------------------------------------------------------------
1 | in(
5 | [
6 | __DIR__ . '/',
7 | __DIR__ . '/bin',
8 | __DIR__ . '/src',
9 | __DIR__ . '/tests',
10 | __DIR__ . '/www',
11 | ]
12 | );
13 |
14 | $config = new PhpCsFixer\Config();
15 | $config
16 | ->setRiskyAllowed(true)
17 | ->setHideProgress(false)
18 | ->setUsingCache(false)
19 | ->setRules(
20 | [
21 | // Basic ruleset is PSR 12
22 | '@PSR12' => true,
23 | // Short array syntax
24 | 'array_syntax' => ['syntax' => 'short'],
25 | // List of values separated by a comma is contained on a single line should not have a trailing comma like [$foo, $bar,] = ...
26 | 'no_trailing_comma_in_singleline' => true,
27 | // Arrays on multiline should have a trailing comma
28 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']],
29 | // Align elements in multiline array and variable declarations on new lines below each other
30 | 'binary_operator_spaces' => ['operators' => ['=>' => 'align_single_space_minimal', '=' => 'align']],
31 | // The "No break" comment in switch statements
32 | 'no_break_comment' => ['comment_text' => 'No break'],
33 | // Remove unused imports
34 | 'no_unused_imports' => true,
35 | // Classes from the global namespace should not be imported
36 | 'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
37 | // Alpha order imports
38 | 'ordered_imports' => ['imports_order' => ['class', 'function', 'const'], 'sort_algorithm' => 'alpha'],
39 | // There should not be useless else cases
40 | 'no_useless_else' => true,
41 | // Native function invocation
42 | 'native_function_invocation' => ['include' => ['@compiler_optimized']],
43 | ]
44 | )
45 | ->setFinder($finder);
46 |
47 | return $config;
48 |
--------------------------------------------------------------------------------
/.scrutinizer.yml:
--------------------------------------------------------------------------------
1 | imports:
2 | - php
3 |
4 | tools:
5 | external_code_coverage: true
6 |
7 | filter:
8 | excluded_paths:
9 | - tests/*
10 | - vendor/*
11 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Joomla Environment Stats
2 |
3 | In order to better understand our install base and end user environments, a plugin has been created to send those stats back to a Joomla
4 | controlled central server. No worries though, __no__ identifying data is captured at any point, and we only keep the data last sent to us.
5 |
6 | ## Build Status
7 | Travis-CI: [](https://travis-ci.org/joomla/statistics-server)
8 | Scrutinizer-CI: [](https://scrutinizer-ci.com/g/joomla/statistics-server/?branch=master) [](https://scrutinizer-ci.com/g/joomla/statistics-server/?branch=master) [](https://scrutinizer-ci.com/g/joomla/statistics-server/build-status/master)
9 |
10 | ## Requirements
11 |
12 | * PHP 8.1+
13 | * PDO with MySQL support
14 | * MySQL
15 | * Composer
16 | * Apache with mod_rewrite enabled and configured to allow the .htaccess file to be read
17 |
18 | ## Installation
19 |
20 | 1. Clone this repo on your web server
21 | 2. Create a database on your MySQL server
22 | 3. Copy `etc/config.dist.json` to `etc/config.json` and fill in your database credentials
23 | 4. Run the `composer install` command to install all dependencies
24 | 5. Run the `bin/stats install` command to create the application's database
25 |
26 | ## Additional Configuration
27 |
28 | The `DisplayStatisticsController` optionally supports additional configuration values which affect the application's behavior, to include:
29 |
30 | * Raw Data Access - The API supports requesting the raw, unfiltered API data by sending a `Joomla-Raw` header with the API request. The value of this must match the `stats.rawdata` configuration key.
31 |
32 | Additionally, the application behavior is affected by the following configuration settings:
33 |
34 | * Error Reporting - The `errorReporting` configuration key can be set to a valid bitmask to be passed into the `error_reporting()` function
35 | * Logging - The application's logging levels can be fine tuned by adjusting the `log` configuration keys:
36 | * `log.level` - The default logging level to use for all application loggers
37 | * `log.application` - The logging level to use specifically for the `monolog.handler.application` logger; defaults to the `log.level` value
38 | * `log.database` - The logging level to use specifically for the `monolog.handler.database` logger; defaults to the `log.level` value (Note: if `database.debug` is set to true then this level will ALWAYS correspond to the debug level)
39 |
40 | ## Deployments
41 | * Joomla's Jenkins server will automatically push any commits to the `master` branch to the production server
42 | * TODO - Future iterations of this setup should require a passing Travis-CI build before deploying
43 | * Because of the use of custom delimiters in the database schema (which are not parsed correctly with PDO), database migrations are not automatically executed
44 | * If a change is pushed that includes updates to the database schema, then the merger needs to log into the server and run any migrations required; the application's `database:migrate` command will take care of this
45 | * `php /path/to/application/bin/stats database:migrate`
46 | * Don’t put any triggers inside the migrations, those should be added to the main `etc/mysql.sql` schema file then manually run on the database using your preferred database management tool
47 |
--------------------------------------------------------------------------------
/bin/stats:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | run();
26 | }
27 | catch (\Throwable $throwable)
28 | {
29 | error_log($throwable);
30 |
31 | fwrite(STDOUT, "\nAn error occurred while running the application: " . $throwable->getMessage() . "\n");
32 | fwrite(STDOUT, "\n" . $throwable->getTraceAsString() . "\n");
33 |
34 | if ($prev = $throwable->getPrevious())
35 | {
36 | fwrite(STDOUT, "\n\nPrevious Exception: " . $prev->getMessage() . "\n");
37 | fwrite(STDOUT, "\n" . $prev->getTraceAsString() . "\n");
38 | }
39 |
40 | exit(1);
41 | }
42 |
--------------------------------------------------------------------------------
/cache/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "joomla/statistics-server",
3 | "type": "project",
4 | "description": "Joomla Stats Collection Server",
5 | "keywords": ["joomla"],
6 | "homepage": "http://github.com/joomla/statistics-server",
7 | "license": "GPL-2.0-or-later",
8 | "require": {
9 | "php": "^8.1.0",
10 | "ext-json": "*",
11 | "ext-pdo": "*",
12 | "joomla/application": "^3.0",
13 | "joomla/console": "^3.0",
14 | "joomla/controller": "^3.0",
15 | "joomla/database": "^3.0",
16 | "joomla/event": "^3.0",
17 | "joomla/di": "^3.0",
18 | "joomla/filter": "^3.0",
19 | "joomla/github": "^3.0-dev",
20 | "joomla/http": "^3.0",
21 | "joomla/input": "^3.0",
22 | "joomla/registry": "^3.0",
23 | "joomla/router": "^3.0",
24 | "joomla/string": "^3.0",
25 | "joomla/uri": "^3.0",
26 | "joomla/utilities": "^3.0",
27 | "joomla/view": "^3.0",
28 | "league/flysystem": "^1.0.69",
29 | "monolog/monolog": "^2.1",
30 | "psr/log": "^1.1.3",
31 | "ramsey/uuid": "^3.9.2",
32 | "symfony/process": "^5.1",
33 | "theiconic/php-ga-measurement-protocol": "^2.7.2",
34 | "influxdata/influxdb-client-php": "^3.5"
35 | },
36 | "require-dev": {
37 | "friendsofphp/php-cs-fixer": "^v3.58.1",
38 | "joomla/coding-standards": "~3.0@dev",
39 | "joomla/test": "^2.0@beta",
40 | "league/flysystem-memory": "^1.0.2",
41 | "phpunit/phpunit": "^10.5.20"
42 | },
43 | "autoload": {
44 | "psr-4": {
45 | "Joomla\\StatsServer\\": "src/"
46 | }
47 | },
48 | "autoload-dev": {
49 | "psr-4": {
50 | "Joomla\\StatsServer\\Tests\\": "tests/"
51 | }
52 | },
53 | "replace": {
54 | "paragonie/random_compat": "*",
55 | "symfony/polyfill-php55": "*",
56 | "symfony/polyfill-php70": "*",
57 | "symfony/polyfill-php72": "*"
58 | },
59 | "config": {
60 | "platform": {
61 | "php": "8.1.0"
62 | },
63 | "allow-plugins": {
64 | "php-http/discovery": true
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/etc/config.dist.json:
--------------------------------------------------------------------------------
1 | {
2 | "database": {
3 | "driver" : "mysql",
4 | "host" : "",
5 | "user" : "",
6 | "password": "",
7 | "database": "",
8 | "prefix" : ""
9 | },
10 | "influxdb": {
11 | "url" : "",
12 | "token" : "",
13 | "org" : "",
14 | "bucket": ""
15 | },
16 | "stats": {
17 | "rawdata": false
18 | },
19 | "log": {
20 | "level": "error"
21 | },
22 | "github": {
23 | "gh": {
24 | "token": false
25 | },
26 | "api": {
27 | "username": false,
28 | "password": false
29 | }
30 | },
31 | "errorReporting": 0
32 | }
33 |
--------------------------------------------------------------------------------
/etc/migrations/20160618001.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS `#__jstats` (
2 | `unique_id` varchar(40) NOT NULL,
3 | `php_version` varchar(15) NOT NULL,
4 | `db_type` varchar(15) NOT NULL,
5 | `db_version` varchar(50) NOT NULL,
6 | `cms_version` varchar(15) NOT NULL,
7 | `server_os` varchar(255) NOT NULL,
8 | `modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
9 | PRIMARY KEY (`unique_id`),
10 | KEY `idx_php_version` (`php_version`),
11 | KEY `idx_db_type` (`db_type`),
12 | KEY `idx_db_version` (`db_version`),
13 | KEY `idx_cms_version` (`cms_version`),
14 | KEY `idx_server_os` (`server_os`),
15 | KEY `idx_database` (`db_type`, `db_version`)
16 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
17 |
18 | CREATE TABLE `#__migrations` (
19 | `version` varchar(25) NOT NULL COMMENT 'Applied migration versions',
20 | KEY `version` (`version`)
21 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
22 |
--------------------------------------------------------------------------------
/etc/migrations/20180413001.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_php_version` (
2 | `php_version` varchar(15) NOT NULL,
3 | `count` INT NOT NULL,
4 | PRIMARY KEY (`php_version`),
5 | KEY `idx_version_count` (`php_version`, `count`)
6 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
7 |
8 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_db_version` (
9 | `db_version` varchar(50) NOT NULL,
10 | `count` INT NOT NULL,
11 | PRIMARY KEY (`db_version`),
12 | KEY `idx_version_count` (`db_version`, `count`)
13 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
14 |
15 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_db_type` (
16 | `db_type` varchar(15) NOT NULL,
17 | `count` INT NOT NULL,
18 | PRIMARY KEY (`db_type`),
19 | KEY `idx_version_count` (`db_type`, `count`)
20 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
21 |
22 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_cms_version` (
23 | `cms_version` varchar(15) NOT NULL,
24 | `count` INT NOT NULL,
25 | PRIMARY KEY (`cms_version`),
26 | KEY `idx_version_count` (`cms_version`, `count`)
27 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
28 |
29 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_server_os` (
30 | `server_os` varchar(255) NOT NULL,
31 | `count` INT NOT NULL,
32 | PRIMARY KEY (`server_os`),
33 | KEY `idx_version_count` (`server_os`, `count`)
34 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
35 |
--------------------------------------------------------------------------------
/etc/migrations/20200313001.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_cms_php_version` (
2 | `cms_version` varchar(15) NOT NULL,
3 | `php_version` varchar(15) NOT NULL,
4 | `count` INT NOT NULL,
5 | PRIMARY KEY (`cms_version`,`php_version`)
6 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
7 |
8 | CREATE TABLE IF NOT EXISTS `#__jstats_counter_db_type_version` (
9 | `db_type` varchar(15) NOT NULL,
10 | `db_version` varchar(15) NOT NULL,
11 | `count` INT NOT NULL,
12 | PRIMARY KEY (`db_type`,`db_version`)
13 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;
14 |
--------------------------------------------------------------------------------
/etc/unatantum.sql:
--------------------------------------------------------------------------------
1 | INSERT INTO `#__jstats_counter_server_os`
2 | SELECT server_os , COUNT(*) as count
3 | FROM `#__jstats`
4 | GROUP BY server_os;
5 |
6 | INSERT INTO `#__jstats_counter_php_version`
7 | SELECT php_version , COUNT(*) as count
8 | FROM `#__jstats`
9 | GROUP BY php_version;
10 |
11 | INSERT INTO `#__jstats_counter_db_version`
12 | SELECT db_version , COUNT(*) as count
13 | FROM `#__jstats`
14 | GROUP BY db_version;
15 |
16 | INSERT INTO `#__jstats_counter_db_type`
17 | SELECT db_type , COUNT(*) as count
18 | FROM `#__jstats`
19 | GROUP BY db_type;
20 |
21 | INSERT INTO `#__jstats_counter_cms_version`
22 | SELECT cms_version , COUNT(*) as count
23 | FROM `#__jstats`
24 | GROUP BY cms_version;
25 |
26 | INSERT INTO `#__jstats_counter_cms_php_version`
27 | SELECT cms_version, php_version , COUNT(*) as count
28 | FROM `#__jstats`
29 | GROUP BY cms_version, php_version;
30 |
31 | INSERT INTO `#__jstats_counter_db_type_version`
32 | SELECT db_type, db_version , COUNT(*) as count
33 | FROM `#__jstats`
34 | GROUP BY db_type, db_version;
35 |
--------------------------------------------------------------------------------
/logs/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | tests
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | src
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/ruleset.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | */build/*
12 | */cache/*
13 | */etc/*
14 | */logs/*
15 | */snapshots/*
16 | */tests/*
17 | */versions/*
18 |
19 |
20 | */vendor/*
21 |
22 |
23 | */Gruntfile.js
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | www/index\.php
37 |
38 |
39 | ./bin
40 | ./src
41 | ./tests
42 | ./www
43 |
44 |
45 |
--------------------------------------------------------------------------------
/snapshots/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/src/Commands/Database/MigrateCommand.php:
--------------------------------------------------------------------------------
1 | migrations = $migrations;
50 |
51 | parent::__construct();
52 | }
53 |
54 | /**
55 | * Internal function to execute the command.
56 | *
57 | * @param InputInterface $input The input to inject into the command.
58 | * @param OutputInterface $output The output to inject into the command.
59 | *
60 | * @return integer The command exit code
61 | */
62 | protected function doExecute(InputInterface $input, OutputInterface $output): int
63 | {
64 | $symfonyStyle = new SymfonyStyle($input, $output);
65 |
66 | $symfonyStyle->title('Database Migrations: Migrate');
67 |
68 | // If a version is given, we are only executing that migration
69 | $version = $input->getOption('mversion');
70 |
71 | try {
72 | $this->migrations->migrateDatabase($version);
73 | } catch (\Exception $exception) {
74 | $this->logger->critical(
75 | 'Error migrating database',
76 | ['exception' => $exception]
77 | );
78 |
79 | $symfonyStyle->error(sprintf('Error migrating database: %s', $exception->getMessage()));
80 |
81 | return 1;
82 | }
83 |
84 | if ($version) {
85 | $message = sprintf('Database migrated to version "%s".', $version);
86 | } else {
87 | $message = 'Database migrated to latest version.';
88 | }
89 |
90 | $this->logger->info($message);
91 |
92 | $symfonyStyle->success($message);
93 |
94 | return 0;
95 | }
96 |
97 | /**
98 | * Configures the current command.
99 | *
100 | * @return void
101 | */
102 | protected function configure(): void
103 | {
104 | $this->setDescription('Migrate the database schema to a newer version.');
105 | $this->addOption(
106 | 'mversion',
107 | null,
108 | InputOption::VALUE_OPTIONAL,
109 | 'If specified, only the given migration will be executed if necessary.'
110 | );
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/Commands/Database/MigrationStatusCommand.php:
--------------------------------------------------------------------------------
1 | migrations = $migrations;
45 |
46 | parent::__construct();
47 | }
48 |
49 | /**
50 | * Internal function to execute the command.
51 | *
52 | * @param InputInterface $input The input to inject into the command.
53 | * @param OutputInterface $output The output to inject into the command.
54 | *
55 | * @return integer The command exit code
56 | */
57 | protected function doExecute(InputInterface $input, OutputInterface $output): int
58 | {
59 | $symfonyStyle = new SymfonyStyle($input, $output);
60 |
61 | $symfonyStyle->title('Database Migrations: Check Status');
62 |
63 | $status = $this->migrations->checkStatus();
64 |
65 | if (!$status->tableExists) {
66 | $symfonyStyle->comment('The migrations table does not exist, run the "database:migrate" command to set up the database.');
67 | } elseif ($status->latest) {
68 | $symfonyStyle->success('Your database is up-to-date.');
69 | } else {
70 | $symfonyStyle->comment(sprintf('Your database is not up-to-date. You are missing %d migration(s).', $status->missingMigrations));
71 |
72 | $symfonyStyle->table(
73 | [
74 | 'Current Version',
75 | 'Latest Version',
76 | ],
77 | [
78 | [
79 | $status->currentVersion,
80 | $status->latestVersion,
81 | ],
82 | ]
83 | );
84 |
85 | $symfonyStyle->comment('To update, run the "database:migrate" command.');
86 | }
87 |
88 | return 0;
89 | }
90 |
91 | /**
92 | * Configures the current command.
93 | *
94 | * @return void
95 | */
96 | protected function configure(): void
97 | {
98 | $this->setDescription('Check the database migration status.');
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/Commands/SnapshotCommand.php:
--------------------------------------------------------------------------------
1 | view = $view;
57 | $this->filesystem = $filesystem;
58 |
59 | parent::__construct();
60 | }
61 |
62 | /**
63 | * Internal function to execute the command.
64 | *
65 | * @param InputInterface $input The input to inject into the command.
66 | * @param OutputInterface $output The output to inject into the command.
67 | *
68 | * @return integer The command exit code
69 | */
70 | protected function doExecute(InputInterface $input, OutputInterface $output): int
71 | {
72 | $symfonyStyle = new SymfonyStyle($input, $output);
73 |
74 | $symfonyStyle->title('Creating Statistics Snapshot');
75 |
76 | // We want the full raw data set for our snapshot
77 | $this->view->isAuthorizedRaw(true);
78 |
79 | $source = $input->getOption('source');
80 |
81 | $filename = date('YmdHis');
82 |
83 | if ($source) {
84 | if (!\in_array($source, StatisticsRepository::ALLOWED_SOURCES)) {
85 | throw new InvalidOptionException(
86 | \sprintf(
87 | 'Invalid source "%s" given, valid options are: %s',
88 | $source,
89 | implode(', ', StatisticsRepository::ALLOWED_SOURCES)
90 | )
91 | );
92 | }
93 |
94 | $this->view->setSource($source);
95 |
96 | $filename .= '_' . $source;
97 | }
98 |
99 | if (!$this->filesystem->write($filename, $this->view->render())) {
100 | $symfonyStyle->error('Failed writing snapshot to the filesystem.');
101 |
102 | return 1;
103 | }
104 |
105 | $symfonyStyle->success('Snapshot recorded.');
106 |
107 | return 0;
108 | }
109 |
110 | /**
111 | * Configures the current command.
112 | *
113 | * @return void
114 | */
115 | protected function configure(): void
116 | {
117 | $this->setDescription('Takes a snapshot of the statistics data.');
118 | $this->addOption(
119 | 'source',
120 | null,
121 | InputOption::VALUE_OPTIONAL,
122 | 'If given, filters the snapshot to a single source.'
123 | );
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/Commands/SnapshotRecentlyUpdatedCommand.php:
--------------------------------------------------------------------------------
1 | view = $view;
57 | $this->filesystem = $filesystem;
58 |
59 | parent::__construct();
60 | }
61 |
62 | /**
63 | * Internal function to execute the command.
64 | *
65 | * @param InputInterface $input The input to inject into the command.
66 | * @param OutputInterface $output The output to inject into the command.
67 | *
68 | * @return integer The command exit code
69 | */
70 | protected function doExecute(InputInterface $input, OutputInterface $output): int
71 | {
72 | $symfonyStyle = new SymfonyStyle($input, $output);
73 |
74 | $symfonyStyle->title('Creating Statistics Snapshot');
75 |
76 | // We want the full raw data set for our snapshot
77 | $this->view->isAuthorizedRaw(true);
78 | $this->view->isRecent(true);
79 |
80 | $source = $input->getOption('source');
81 |
82 | $filename = date('YmdHis') . '_recent';
83 |
84 | if ($source) {
85 | if (!\in_array($source, StatisticsRepository::ALLOWED_SOURCES)) {
86 | throw new InvalidOptionException(
87 | \sprintf(
88 | 'Invalid source "%s" given, valid options are: %s',
89 | $source,
90 | implode(', ', StatisticsRepository::ALLOWED_SOURCES)
91 | )
92 | );
93 | }
94 |
95 | $this->view->setSource($source);
96 |
97 | $filename .= '_' . $source;
98 | }
99 |
100 | if (!$this->filesystem->write($filename, $this->view->render())) {
101 | $symfonyStyle->error('Failed writing snapshot to the filesystem.');
102 |
103 | return 1;
104 | }
105 |
106 | $symfonyStyle->success('Snapshot recorded.');
107 |
108 | return 0;
109 | }
110 |
111 | /**
112 | * Configures the current command.
113 | *
114 | * @return void
115 | */
116 | protected function configure(): void
117 | {
118 | $this->setDescription('Takes a snapshot of the recently updated statistics data.');
119 | $this->addOption(
120 | 'source',
121 | null,
122 | InputOption::VALUE_OPTIONAL,
123 | 'If given, filters the snapshot to a single source.'
124 | );
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/Commands/Tags/AbstractTagCommand.php:
--------------------------------------------------------------------------------
1 | github = $github;
68 | $this->filesystem = $filesystem;
69 |
70 | parent::__construct();
71 | }
72 |
73 | /**
74 | * Internal hook to initialise the command after the input has been bound and before the input is validated.
75 | *
76 | * @param InputInterface $input The input to inject into the command.
77 | * @param OutputInterface $output The output to inject into the command.
78 | *
79 | * @return void
80 | */
81 | protected function initialise(InputInterface $input, OutputInterface $output): void
82 | {
83 | $this->io = new SymfonyStyle($input, $output);
84 | }
85 |
86 | /**
87 | * Get the tags for a repository
88 | *
89 | * @return array
90 | */
91 | protected function getTags(): array
92 | {
93 | $tags = [];
94 |
95 | $this->io->comment('Fetching page 1 of tags.');
96 |
97 | // Get the first page so we can process the headers to figure out how many times we need to do this
98 | $tags = array_merge($tags, $this->github->repositories->getTags($this->repoOwner, $this->repoName, 1));
99 |
100 | $response = $this->github->repositories->getApiResponse();
101 |
102 | if ($response->hasHeader('Link')) {
103 | preg_match('/(\?page=[0-9]+>; rel=\"last\")/', $response->getHeader('Link')[0], $matches);
104 |
105 | if ($matches && isset($matches[0])) {
106 | preg_match('/\d+/', $matches[0], $pages);
107 |
108 | $lastPage = $pages[0];
109 |
110 | for ($page = 2; $page <= $lastPage; $page++) {
111 | $this->io->comment(sprintf('Fetching page %d of %d pages of tags.', $page, $lastPage));
112 |
113 | $tags = array_merge($tags, $this->github->repositories->getTags($this->repoOwner, $this->repoName, $page));
114 | }
115 | }
116 | }
117 |
118 | return $tags;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/Commands/Tags/FetchJoomlaTagsCommand.php:
--------------------------------------------------------------------------------
1 | repoName = 'joomla-cms';
43 | $this->repoOwner = 'joomla';
44 | }
45 |
46 | /**
47 | * Internal function to execute the command.
48 | *
49 | * @param InputInterface $input The input to inject into the command.
50 | * @param OutputInterface $output The output to inject into the command.
51 | *
52 | * @return integer The command exit code
53 | */
54 | protected function doExecute(InputInterface $input, OutputInterface $output): int
55 | {
56 | $this->io->title('Fetching Joomla Releases');
57 |
58 | $versions = [];
59 | $highVersion = '0.0.0';
60 |
61 | foreach ($this->getTags() as $tag) {
62 | $version = $this->validateVersionNumber($tag->name);
63 |
64 | // Only process if the tag name looks like a version number
65 | if ($version === false) {
66 | continue;
67 | }
68 |
69 | // Joomla only uses major.minor.patch so everything else is invalid
70 | $explodedVersion = explode('.', $version);
71 |
72 | if (\count($explodedVersion) != 3) {
73 | continue;
74 | }
75 |
76 | // Version collection is valid for the 3.x series and later
77 | if (version_compare($version, '3.0.0', '<')) {
78 | continue;
79 | }
80 |
81 | // We have a valid version number, great news... add it to our array if it isn't already present
82 | if (!\in_array($version, $versions)) {
83 | $versions[] = $version;
84 |
85 | // If this version is higher than our high version, replace it
86 | // TODO - When 4.0 is stable adjust this logic
87 | if (version_compare($version, '4.0', '<') && version_compare($version, $highVersion, '>')) {
88 | $highVersion = $version;
89 | }
90 | }
91 | }
92 |
93 | // If the high version is not the default then let's add some (arbitrary) allowed versions based on the repo's dev structure
94 | if ($highVersion !== '0.0.0') {
95 | $explodedVersion = explode('.', $highVersion);
96 |
97 | // Allow the next patch release after this one
98 | $nextPatch = $explodedVersion[2] + 1;
99 | $versions[] = $explodedVersion[0] . '.' . $explodedVersion[1] . '.' . $nextPatch;
100 |
101 | // And allow the next minor release after this one
102 | $nextMinor = $explodedVersion[1] + 1;
103 | $versions[] = $explodedVersion[0] . '.' . $nextMinor . '.0';
104 | }
105 |
106 | if (!$this->filesystem->put('joomla.json', json_encode($versions))) {
107 | $this->io->error('Failed writing version data to the filesystem.');
108 |
109 | return 1;
110 | }
111 |
112 | $this->io->success('Joomla! versions updated.');
113 |
114 | return 0;
115 | }
116 |
117 | /**
118 | * Configures the current command.
119 | *
120 | * @return void
121 | */
122 | protected function configure(): void
123 | {
124 | $this->setDescription('Parses the release tags for the Joomla! CMS GitHub repository.');
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/Commands/Tags/FetchPhpTagsCommand.php:
--------------------------------------------------------------------------------
1 | repoName = 'php-src';
43 | $this->repoOwner = 'php';
44 | }
45 |
46 | /**
47 | * Internal function to execute the command.
48 | *
49 | * @param InputInterface $input The input to inject into the command.
50 | * @param OutputInterface $output The output to inject into the command.
51 | *
52 | * @return integer The command exit code
53 | */
54 | protected function doExecute(InputInterface $input, OutputInterface $output): int
55 | {
56 | $this->io->title('Fetching PHP Releases');
57 |
58 | $versions = [];
59 | $supportedBranches = [
60 | '7.2' => '',
61 | '7.3' => '',
62 | '7.4' => '',
63 | ];
64 |
65 | foreach ($this->getTags() as $tag) {
66 | // Replace 'php-' from the tag to get our version number; skip if the segment doesn't exist
67 | if (strpos($tag->name, 'php-') !== 0) {
68 | continue;
69 | }
70 |
71 | $version = substr($tag->name, 4);
72 |
73 | $version = $this->validateVersionNumber($version);
74 |
75 | // Only process if the tag name looks like a version number
76 | if ($version === false) {
77 | continue;
78 | }
79 |
80 | // We only track versions based on major.minor.patch so everything else is invalid
81 | $explodedVersion = explode('.', $version);
82 |
83 | if (\count($explodedVersion) != 3) {
84 | continue;
85 | }
86 |
87 | // Joomla collects stats for the 3.x branch and later, the minimum PHP version for 3.0.0 was 5.3.1
88 | if (version_compare($version, '5.3.1', '<')) {
89 | continue;
90 | }
91 |
92 | // We have a valid version number, great news... add it to our array if it isn't already present
93 | if (!\in_array($version, $versions)) {
94 | $versions[] = $version;
95 |
96 | // If this version is higher than our branch's high version, replace it
97 | $branch = substr($version, 0, 3);
98 |
99 | if (isset($supportedBranches[$branch]) && version_compare($version, $supportedBranches[$branch], '>')) {
100 | $supportedBranches[$branch] = $version;
101 | }
102 | }
103 | }
104 |
105 | // For each supported branch, also add the next patch release
106 | foreach ($supportedBranches as $branch => $version) {
107 | $explodedVersion = explode('.', $version);
108 |
109 | $nextPatch = $explodedVersion[2] + 1;
110 | $versions[] = $explodedVersion[0] . '.' . $explodedVersion[1] . '.' . $nextPatch;
111 | }
112 |
113 | // Use $branch from the previous loop to allow the next minor version (PHP's master branch)
114 | $explodedVersion = explode('.', $branch);
115 |
116 | $nextMinor = $explodedVersion[1] + 1;
117 | $nextRelease = $explodedVersion[0] . '.' . $nextMinor . '.0';
118 |
119 | // There won't be a PHP 7.5, change next release to 8.0 if needed
120 | if ($nextRelease === '7.5.0') {
121 | $nextRelease = '8.0.0';
122 | }
123 |
124 | if (!\in_array($nextRelease, $versions, true)) {
125 | $versions[] = $nextRelease;
126 | }
127 |
128 | if (!$this->filesystem->put('php.json', json_encode($versions))) {
129 | $this->io->error('Failed writing version data to the filesystem.');
130 |
131 | return 1;
132 | }
133 |
134 | $this->io->success('PHP versions updated.');
135 |
136 | return 0;
137 | }
138 |
139 | /**
140 | * Configures the current command.
141 | *
142 | * @return void
143 | */
144 | protected function configure(): void
145 | {
146 | $this->setDescription('Parses the release tags for the PHP GitHub repository.');
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/Commands/UpdateCommand.php:
--------------------------------------------------------------------------------
1 | title('Update Server');
45 | $symfonyStyle->comment('Updating server to git HEAD');
46 |
47 | if (!$this->getHelperSet()) {
48 | $symfonyStyle->error('The helper set has not been registered to the update command.');
49 |
50 | return 1;
51 | }
52 |
53 | /** @var ProcessHelper $processHelper */
54 | $processHelper = $this->getHelperSet()->get('process');
55 |
56 | // Pull from remote repo
57 | try {
58 | $processHelper->mustRun($output, new Process(['git', 'pull'], APPROOT));
59 | } catch (ProcessFailedException $e) {
60 | $this->getApplication()->getLogger()->error('Could not execute `git pull`', ['exception' => $e]);
61 |
62 | $symfonyStyle->error('Error running `git pull`: ' . $e->getMessage());
63 |
64 | return 1;
65 | }
66 |
67 | $symfonyStyle->comment('Updating Composer resources');
68 |
69 | // Run Composer install
70 | try {
71 | $processHelper->mustRun($output, new Process(['composer', 'install', '--no-dev', '-o', '-a'], APPROOT));
72 | } catch (ProcessFailedException $e) {
73 | $this->getApplication()->getLogger()->error('Could not update Composer resources', ['exception' => $e]);
74 |
75 | $symfonyStyle->error('Error updating Composer resources: ' . $e->getMessage());
76 |
77 | return 1;
78 | }
79 |
80 | $symfonyStyle->success('Update complete');
81 |
82 | return 0;
83 | }
84 |
85 | /**
86 | * Configures the current command.
87 | *
88 | * @return void
89 | */
90 | protected function configure(): void
91 | {
92 | $this->setDescription('Update the server to the current git HEAD');
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/Controllers/DisplayStatisticsController.php:
--------------------------------------------------------------------------------
1 | view = $view;
38 | }
39 |
40 | /**
41 | * Execute the controller.
42 | *
43 | * @return boolean
44 | */
45 | public function execute()
46 | {
47 | // Check if we are allowed to receive the raw data
48 | $authorizedRaw = $this->getInput()->server->getString('HTTP_JOOMLA_RAW', 'fail') === $this->getApplication()->get('stats.rawdata', false);
49 |
50 | // Check if a single data source is requested
51 | $source = $this->getInput()->getString('source', '');
52 |
53 | // Check if a timeframe is requested
54 | $timeframe = (int) $this->getInput()->getInt('timeframe', 0);
55 |
56 | $this->view->isAuthorizedRaw($authorizedRaw);
57 | $this->view->setSource($source);
58 | $this->view->setTimeframe($timeframe);
59 |
60 | $this->getApplication()->setBody($this->view->render());
61 |
62 | return true;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Database/Exception/CannotInitializeMigrationsException.php:
--------------------------------------------------------------------------------
1 | database = $database;
48 | $this->filesystem = $filesystem;
49 | }
50 |
51 | /**
52 | * Checks the migration status of the current installation
53 | *
54 | * @return MigrationsStatus
55 | */
56 | public function checkStatus(): MigrationsStatus
57 | {
58 | $response = new MigrationsStatus();
59 |
60 | try {
61 | // First get the list of applied migrations
62 | $appliedMigrations = $this->database->setQuery(
63 | $this->database->getQuery(true)
64 | ->select('version')
65 | ->from('#__migrations')
66 | )->loadColumn();
67 | } catch (ExecutionFailureException | PrepareStatementFailureException $exception) {
68 | // On PDO we're checking "42S02, 1146, Table 'XXX.#__migrations' doesn't exist"
69 | if (strpos($exception->getMessage(), "migrations' doesn't exist") === false) {
70 | throw $exception;
71 | }
72 |
73 | $response->tableExists = false;
74 |
75 | return $response;
76 | }
77 |
78 | // Now get the list of all known migrations
79 | $knownMigrations = [];
80 |
81 | foreach ($this->filesystem->listContents() as $migrationFiles) {
82 | $knownMigrations[] = $migrationFiles['filename'];
83 | }
84 |
85 | // Don't rely on file system ordering.
86 | sort($knownMigrations);
87 |
88 | // Validate all migrations are applied; the count and latest versions should match
89 | if (\count($appliedMigrations) === \count($knownMigrations)) {
90 | $appliedValues = array_values($appliedMigrations);
91 | $knownValues = array_values($knownMigrations);
92 |
93 | $latestApplied = (int) end($appliedValues);
94 | $latestKnown = (int) end($knownValues);
95 |
96 | // Versions match, good to go
97 | if ($latestApplied === $latestKnown) {
98 | $response->latest = true;
99 |
100 | return $response;
101 | }
102 | }
103 |
104 | // The system is not on the latest version, get the relevant data
105 | $response->missingMigrations = \count($knownMigrations) - \count($appliedMigrations);
106 | $response->currentVersion = array_pop($appliedMigrations);
107 | $response->latestVersion = array_pop($knownMigrations);
108 |
109 | return $response;
110 | }
111 |
112 | /**
113 | * Migrate the database
114 | *
115 | * @param string|null $version Optional migration version to run
116 | *
117 | * @return void
118 | */
119 | public function migrateDatabase(?string $version = null): void
120 | {
121 | try {
122 | // Determine the migrations to apply
123 | $appliedMigrations = $this->database->setQuery(
124 | $this->database->getQuery(true)
125 | ->select('version')
126 | ->from('#__migrations')
127 | )->loadColumn();
128 | } catch (ExecutionFailureException | PrepareStatementFailureException $exception) {
129 | // If the table does not exist, we can still try to run migrations
130 | if (strpos($exception->getMessage(), "migrations' doesn't exist") === false) {
131 | throw $exception;
132 | }
133 |
134 | // If given a version, we can only execute it if it is the first migration, otherwise we've got other problems
135 | if ($version !== null && $version !== '') {
136 | $firstMigration = $this->filesystem->listContents()[0];
137 |
138 | if ($firstMigration['filename'] !== $version) {
139 | throw new CannotInitializeMigrationsException(
140 | 'The migrations have not yet been initialized and the first migration has not been given as the version to run.'
141 | );
142 | }
143 | }
144 |
145 | $appliedMigrations = [];
146 | }
147 |
148 | // If a version is specified, check if that migration is already applied and if not, run that one only
149 | if ($version !== null && $version !== '') {
150 | // If it's already applied, there's nothing to do here
151 | if (\in_array($version, $appliedMigrations)) {
152 | return;
153 | }
154 |
155 | $this->doMigration($version);
156 |
157 | return;
158 | }
159 |
160 | // We need to check the known migrations and filter out the applied ones to know what to do
161 | $knownMigrations = [];
162 |
163 | foreach ($this->filesystem->listContents() as $migrationFiles) {
164 | $knownMigrations[] = $migrationFiles['filename'];
165 | }
166 |
167 | foreach (array_diff($knownMigrations, $appliedMigrations) as $version) {
168 | $this->doMigration($version);
169 | }
170 | }
171 |
172 | /**
173 | * Perform the database migration for the specified version
174 | *
175 | * @param string $version Migration version to run
176 | *
177 | * @return void
178 | *
179 | * @throws UnknownMigrationException
180 | * @throws UnreadableFileException
181 | */
182 | private function doMigration(string $version): void
183 | {
184 | $sqlFile = $version . '.sql';
185 |
186 | if (!$this->filesystem->has($sqlFile)) {
187 | throw new UnknownMigrationException($sqlFile);
188 | }
189 |
190 | $queries = $this->filesystem->read($sqlFile);
191 |
192 | if ($queries === false) {
193 | throw new UnreadableFileException(
194 | sprintf(
195 | 'Could not read data from the %s SQL file, please update the database manually.',
196 | $sqlFile
197 | )
198 | );
199 | }
200 |
201 | foreach (DatabaseDriver::splitSql($queries) as $query) {
202 | $query = trim($query);
203 |
204 | if (!empty($query)) {
205 | $this->database->setQuery($query)->execute();
206 | }
207 | }
208 |
209 | // Log the migration into the database
210 | $this->database->setQuery(
211 | $this->database->getQuery(true)
212 | ->insert('#__migrations')
213 | ->columns('version')
214 | ->values($version)
215 | )->execute();
216 | }
217 | }
218 |
--------------------------------------------------------------------------------
/src/Database/MigrationsStatus.php:
--------------------------------------------------------------------------------
1 | analytics = $analytics;
43 | }
44 |
45 | /**
46 | * Returns an array of events this subscriber will listen to.
47 | *
48 | * @return array
49 | */
50 | public static function getSubscribedEvents(): array
51 | {
52 | return [
53 | ApplicationEvents::BEFORE_EXECUTE => 'onBeforeExecute',
54 | ];
55 | }
56 |
57 | /**
58 | * Logs the visit to analytics if able.
59 | *
60 | * @param ApplicationEvent $event Event object
61 | *
62 | * @return void
63 | */
64 | public function onBeforeExecute(ApplicationEvent $event): void
65 | {
66 | $app = $event->getApplication();
67 |
68 | if (!($app instanceof WebApplicationInterface)) {
69 | return;
70 | }
71 |
72 | // On a GET request to the live domain, submit analytics data
73 | if (
74 | $app->getInput()->getMethod() !== 'GET'
75 | || strpos($app->getInput()->server->getString('HTTP_HOST', ''), 'developer.joomla.org') !== 0
76 | ) {
77 | return;
78 | }
79 |
80 | $this->analytics->setAsyncRequest(true)
81 | ->setProtocolVersion('1')
82 | ->setTrackingId('UA-544070-16')
83 | ->setClientId(Uuid::uuid4()->toString())
84 | ->setDocumentPath($app->get('uri.base.path'))
85 | ->setIpOverride($app->getInput()->server->getString('REMOTE_ADDR', '127.0.0.1'))
86 | ->setUserAgentOverride($app->getInput()->server->getString('HTTP_USER_AGENT', 'JoomlaStats/1.0'));
87 |
88 | // Don't allow sending Analytics data to cause a failure
89 | try {
90 | $this->analytics->sendPageview();
91 | } catch (\Exception $e) {
92 | // Log the error for reference
93 | $this->logger->error(
94 | 'Error sending analytics data.',
95 | ['exception' => $e]
96 | );
97 | }
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/EventListener/ErrorSubscriber.php:
--------------------------------------------------------------------------------
1 | 'handleWebError',
41 | ConsoleEvents::APPLICATION_ERROR => 'handleConsoleError',
42 | ];
43 | }
44 |
45 | /**
46 | * Handle console application errors.
47 | *
48 | * @param ConsoleApplicationErrorEvent $event Event object
49 | *
50 | * @return void
51 | */
52 | public function handleConsoleError(ConsoleApplicationErrorEvent $event): void
53 | {
54 | $this->logger->error(
55 | sprintf('Uncaught Throwable of type %s caught.', \get_class($event->getError())),
56 | ['exception' => $event->getError()]
57 | );
58 |
59 | (new SymfonyStyle($event->getApplication()->getConsoleInput(), $event->getApplication()->getConsoleOutput()))
60 | ->error(sprintf('Uncaught Throwable of type %s caught: %s', \get_class($event->getError()), $event->getError()->getMessage()));
61 | }
62 |
63 | /**
64 | * Handle web application errors.
65 | *
66 | * @param ApplicationErrorEvent $event Event object
67 | *
68 | * @return void
69 | */
70 | public function handleWebError(ApplicationErrorEvent $event): void
71 | {
72 | $app = $event->getApplication();
73 |
74 | switch (true) {
75 | case $event->getError() instanceof MethodNotAllowedException:
76 | // Log the error for reference
77 | $this->logger->error(
78 | sprintf('Route `%s` not supported by method `%s`', $app->get('uri.route'), $app->getInput()->getMethod()),
79 | ['exception' => $event->getError()]
80 | );
81 |
82 | $this->prepareResponse($event);
83 |
84 | $app->setHeader('Allow', implode(', ', $event->getError()->getAllowedMethods()));
85 |
86 | break;
87 |
88 | case $event->getError() instanceof RouteNotFoundException:
89 | // Log the error for reference
90 | $this->logger->error(
91 | sprintf('Route `%s` not found', $app->get('uri.route')),
92 | ['exception' => $event->getError()]
93 | );
94 |
95 | $this->prepareResponse($event);
96 |
97 | break;
98 |
99 | default:
100 | $this->logger->error(
101 | sprintf('Uncaught Throwable of type %s caught.', \get_class($event->getError())),
102 | ['exception' => $event->getError()]
103 | );
104 |
105 | $this->prepareResponse($event);
106 |
107 | break;
108 | }
109 | }
110 |
111 | /**
112 | * Prepare the response for the event
113 | *
114 | * @param ApplicationErrorEvent $event Event object
115 | *
116 | * @return void
117 | */
118 | private function prepareResponse(ApplicationErrorEvent $event): void
119 | {
120 | /** @var WebApplication $app */
121 | $app = $event->getApplication();
122 |
123 | $app->allowCache(false);
124 |
125 | $data = [
126 | 'code' => $event->getError()->getCode(),
127 | 'message' => $event->getError()->getMessage(),
128 | 'error' => true,
129 | ];
130 |
131 | $response = new JsonResponse($data);
132 |
133 | switch ($event->getError()->getCode()) {
134 | case 404:
135 | $response = $response->withStatus(404);
136 |
137 | break;
138 |
139 | case 405:
140 | $response = $response->withStatus(405);
141 |
142 | break;
143 |
144 | case 500:
145 | default:
146 | $response = $response->withStatus(500);
147 |
148 | break;
149 | }
150 |
151 | $app->setResponse($response);
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/GitHub/GitHub.php:
--------------------------------------------------------------------------------
1 | $name)) {
36 | $this->$name = new $class($this->options, $this->client);
37 | }
38 |
39 | return $this->$name;
40 | }
41 |
42 | return parent::__get($name);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/GitHub/Package.php:
--------------------------------------------------------------------------------
1 | package . '\\' . ucfirst($name);
31 |
32 | if (class_exists($class)) {
33 | if (!isset($this->$name)) {
34 | $this->$name = new $class($this->options, $this->client);
35 | }
36 |
37 | return $this->$name;
38 | }
39 |
40 | return parent::__get($name);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/GitHub/Package/Repositories.php:
--------------------------------------------------------------------------------
1 | apiResponse;
35 | }
36 |
37 | /**
38 | * Get a list of tags on a repository.
39 | *
40 | * Note: This is different from the parent `getListTags` method as it adds support for the API's pagination. This extended method can be removed
41 | * if the upstream class gains this support.
42 | *
43 | * @param string $owner Repository owner.
44 | * @param string $repo Repository name.
45 | * @param integer $page The page number from which to get items.
46 | *
47 | * @return object
48 | */
49 | public function getTags($owner, $repo, $page = 0)
50 | {
51 | // Build the request path.
52 | $path = '/repos/' . $owner . '/' . $repo . '/tags';
53 |
54 | // Send the request.
55 | $this->apiResponse = $this->client->get($this->fetchUrl($path, $page));
56 |
57 | return $this->processResponse($this->apiResponse);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/Kernel.php:
--------------------------------------------------------------------------------
1 | booted) {
52 | return;
53 | }
54 |
55 | $this->setContainer($this->buildContainer());
56 |
57 | // Register deprecation logging via Monolog
58 | ErrorHandler::register($this->getContainer()->get(Logger::class), [E_DEPRECATED, E_USER_DEPRECATED], false, false);
59 |
60 | $this->booted = true;
61 | }
62 |
63 | /**
64 | * Check if the Kernel is booted
65 | *
66 | * @return boolean
67 | */
68 | public function isBooted(): bool
69 | {
70 | return $this->booted;
71 | }
72 |
73 | /**
74 | * Run the kernel
75 | *
76 | * @return void
77 | */
78 | public function run(): void
79 | {
80 | $this->boot();
81 |
82 | if (!$this->getContainer()->has(AbstractApplication::class)) {
83 | throw new \RuntimeException('The application has not been registered with the container.');
84 | }
85 |
86 | $this->getContainer()->get(AbstractApplication::class)->execute();
87 | }
88 |
89 | /**
90 | * Build the service container
91 | *
92 | * @return Container
93 | */
94 | protected function buildContainer(): Container
95 | {
96 | $config = $this->loadConfiguration();
97 |
98 | $container = new Container();
99 | $container->share('config', $config);
100 |
101 | $container->registerServiceProvider(new AnalyticsServiceProvider())
102 | ->registerServiceProvider(new ConsoleServiceProvider())
103 | ->registerServiceProvider(new DatabaseProvider())
104 | ->registerServiceProvider(new DatabaseServiceProvider())
105 | ->registerServiceProvider(new EventServiceProvider())
106 | ->registerServiceProvider(new FlysystemServiceProvider())
107 | ->registerServiceProvider(new GitHubServiceProvider())
108 | ->registerServiceProvider(new MonologServiceProvider())
109 | ->registerServiceProvider(new RepositoryServiceProvider())
110 | ->registerServiceProvider(new WebApplicationServiceProvider());
111 |
112 | $container->share(
113 | \InfluxDB2\Client::class,
114 | function (Container $container) {
115 | /** @var \Joomla\Registry\Registry $config */
116 | $config = $container->get('config');
117 | $options = (array) $config->get('influxdb');
118 |
119 | return new \InfluxDB2\Client($options);
120 | }
121 | );
122 |
123 |
124 | return $container;
125 | }
126 |
127 | /**
128 | * Load the application's configuration
129 | *
130 | * @return Registry
131 | */
132 | private function loadConfiguration(): Registry
133 | {
134 | $registry = new Registry();
135 | $registry->loadFile(APPROOT . '/etc/config.dist.json');
136 |
137 | if (file_exists(APPROOT . '/etc/config.json')) {
138 | $registry->loadFile(APPROOT . '/etc/config.json');
139 | }
140 |
141 | return $registry;
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/Kernel/ConsoleKernel.php:
--------------------------------------------------------------------------------
1 | alias(AbstractApplication::class, Application::class);
35 |
36 | // Alias the web application logger as the primary logger for the environment
37 | $container->alias('monolog', 'monolog.logger.cli')
38 | ->alias('logger', 'monolog.logger.cli')
39 | ->alias(Logger::class, 'monolog.logger.cli')
40 | ->alias(LoggerInterface::class, 'monolog.logger.cli');
41 |
42 | // Set error reporting based on config
43 | $errorReporting = (int) $container->get('config')->get('errorReporting', 0);
44 | error_reporting($errorReporting);
45 |
46 | return $container;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Kernel/WebKernel.php:
--------------------------------------------------------------------------------
1 | alias(AbstractApplication::class, AbstractWebApplication::class);
35 |
36 | // Alias the web application logger as the primary logger for the environment
37 | $container->alias('monolog', 'monolog.logger.application')
38 | ->alias('logger', 'monolog.logger.application')
39 | ->alias(Logger::class, 'monolog.logger.application')
40 | ->alias(LoggerInterface::class, 'monolog.logger.application');
41 |
42 | // Set error reporting based on config
43 | $errorReporting = (int) $container->get('config')->get('errorReporting', 0);
44 | error_reporting($errorReporting);
45 |
46 | return $container;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Providers/AnalyticsServiceProvider.php:
--------------------------------------------------------------------------------
1 | share(Analytics::class, [$this, 'getAnalyticsService']);
31 | }
32 |
33 | /**
34 | * Get the Analytics class service
35 | *
36 | * @param Container $container The DI container.
37 | *
38 | * @return Analytics
39 | */
40 | public function getAnalyticsService(Container $container): Analytics
41 | {
42 | return new Analytics(true);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Providers/ConsoleServiceProvider.php:
--------------------------------------------------------------------------------
1 | share(Application::class, [$this, 'getConsoleApplicationService']);
50 |
51 | /*
52 | * Application Helpers and Dependencies
53 | */
54 |
55 | $container->alias(ContainerLoader::class, LoaderInterface::class)
56 | ->share(LoaderInterface::class, [$this, 'getCommandLoaderService']);
57 |
58 | /*
59 | * Commands
60 | */
61 |
62 | $container->share(DebugEventDispatcherCommand::class, [$this, 'getDebugEventDispatcherCommandService']);
63 | $container->share(DebugRouterCommand::class, [$this, 'getDebugRouterCommandService']);
64 | $container->share(MigrateCommand::class, [$this, 'getDatabaseMigrateCommandService']);
65 | $container->share(MigrationStatusCommand::class, [$this, 'getDatabaseMigrationStatusCommandService']);
66 | $container->share(FetchJoomlaTagsCommand::class, [$this, 'getFetchJoomlaTagsCommandService']);
67 | $container->share(FetchPhpTagsCommand::class, [$this, 'getFetchPhpTagsCommandService']);
68 | $container->share(SnapshotCommand::class, [$this, 'getSnapshotCommandService']);
69 | $container->share(SnapshotRecentlyUpdatedCommand::class, [$this, 'getSnapshotRecentlyUpdatedCommandService']);
70 | $container->share(UpdateCommand::class, [$this, 'getUpdateCommandService']);
71 | }
72 |
73 | /**
74 | * Get the LoaderInterface service
75 | *
76 | * @param Container $container The DI container.
77 | *
78 | * @return LoaderInterface
79 | */
80 | public function getCommandLoaderService(Container $container): LoaderInterface
81 | {
82 | $mapping = [
83 | DebugEventDispatcherCommand::getDefaultName() => DebugEventDispatcherCommand::class,
84 | DebugRouterCommand::getDefaultName() => DebugRouterCommand::class,
85 | MigrationStatusCommand::getDefaultName() => MigrationStatusCommand::class,
86 | MigrateCommand::getDefaultName() => MigrateCommand::class,
87 | FetchJoomlaTagsCommand::getDefaultName() => FetchJoomlaTagsCommand::class,
88 | FetchPhpTagsCommand::getDefaultName() => FetchPhpTagsCommand::class,
89 | SnapshotCommand::getDefaultName() => SnapshotCommand::class,
90 | SnapshotRecentlyUpdatedCommand::getDefaultName() => SnapshotRecentlyUpdatedCommand::class,
91 | UpdateCommand::getDefaultName() => UpdateCommand::class,
92 | ];
93 |
94 | return new ContainerLoader($container, $mapping);
95 | }
96 |
97 | /**
98 | * Get the console Application service
99 | *
100 | * @param Container $container The DI container.
101 | *
102 | * @return Application
103 | */
104 | public function getConsoleApplicationService(Container $container): Application
105 | {
106 | $application = new Application(new ArgvInput(), new ConsoleOutput(), $container->get('config'));
107 |
108 | $application->setCommandLoader($container->get(LoaderInterface::class));
109 | $application->setDispatcher($container->get(DispatcherInterface::class));
110 | $application->setLogger($container->get(LoggerInterface::class));
111 | $application->setName('Joomla! Statistics Server');
112 |
113 | return $application;
114 | }
115 |
116 | /**
117 | * Get the MigrateCommand service
118 | *
119 | * @param Container $container The DI container.
120 | *
121 | * @return MigrateCommand
122 | */
123 | public function getDatabaseMigrateCommandService(Container $container): MigrateCommand
124 | {
125 | $command = new MigrateCommand($container->get(Migrations::class));
126 | $command->setLogger($container->get(LoggerInterface::class));
127 |
128 | return $command;
129 | }
130 |
131 | /**
132 | * Get the MigrationStatusCommand service
133 | *
134 | * @param Container $container The DI container.
135 | *
136 | * @return MigrationStatusCommand
137 | */
138 | public function getDatabaseMigrationStatusCommandService(Container $container): MigrationStatusCommand
139 | {
140 | return new MigrationStatusCommand($container->get(Migrations::class));
141 | }
142 |
143 | /**
144 | * Get the DebugEventDispatcherCommand service
145 | *
146 | * @param Container $container The DI container.
147 | *
148 | * @return DebugEventDispatcherCommand
149 | */
150 | public function getDebugEventDispatcherCommandService(Container $container): DebugEventDispatcherCommand
151 | {
152 | return new DebugEventDispatcherCommand($container->get(DispatcherInterface::class));
153 | }
154 |
155 | /**
156 | * Get the DebugRouterCommand service
157 | *
158 | * @param Container $container The DI container.
159 | *
160 | * @return DebugRouterCommand
161 | */
162 | public function getDebugRouterCommandService(Container $container): DebugRouterCommand
163 | {
164 | return new DebugRouterCommand($container->get(Router::class));
165 | }
166 |
167 | /**
168 | * Get the FetchJoomlaTagsCommand service
169 | *
170 | * @param Container $container The DI container.
171 | *
172 | * @return FetchJoomlaTagsCommand
173 | */
174 | public function getFetchJoomlaTagsCommandService(Container $container): FetchJoomlaTagsCommand
175 | {
176 | return new FetchJoomlaTagsCommand($container->get(GitHub::class), $container->get('filesystem.versions'));
177 | }
178 |
179 | /**
180 | * Get the FetchPhpTagsCommand class service
181 | *
182 | * @param Container $container The DI container.
183 | *
184 | * @return FetchPhpTagsCommand
185 | */
186 | public function getFetchPhpTagsCommandService(Container $container): FetchPhpTagsCommand
187 | {
188 | return new FetchPhpTagsCommand($container->get(GitHub::class), $container->get('filesystem.versions'));
189 | }
190 |
191 | /**
192 | * Get the SnapshotCommand service
193 | *
194 | * @param Container $container The DI container.
195 | *
196 | * @return SnapshotCommand
197 | */
198 | public function getSnapshotCommandService(Container $container): SnapshotCommand
199 | {
200 | return new SnapshotCommand($container->get(StatsJsonView::class), $container->get('filesystem.snapshot'));
201 | }
202 |
203 | /**
204 | * Get the SnapshotRecentlyUpdatedCommand service
205 | *
206 | * @param Container $container The DI container.
207 | *
208 | * @return SnapshotRecentlyUpdatedCommand
209 | */
210 | public function getSnapshotRecentlyUpdatedCommandService(Container $container): SnapshotRecentlyUpdatedCommand
211 | {
212 | return new SnapshotRecentlyUpdatedCommand($container->get(StatsJsonView::class), $container->get('filesystem.snapshot'));
213 | }
214 |
215 | /**
216 | * Get the UpdateCommand class service
217 | *
218 | * @param Container $container The DI container.
219 | *
220 | * @return UpdateCommand
221 | */
222 | public function getUpdateCommandService(Container $container): UpdateCommand
223 | {
224 | return new UpdateCommand();
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/src/Providers/DatabaseServiceProvider.php:
--------------------------------------------------------------------------------
1 | extend(DatabaseDriver::class, [$this, 'extendDatabaseDriverService']);
33 |
34 | $container->alias('db.monitor.logging', LoggingMonitor::class)
35 | ->share(LoggingMonitor::class, [$this, 'getDbMonitorLoggingService']);
36 |
37 | $container->alias('db.migrations', Migrations::class)
38 | ->share(Migrations::class, [$this, 'getDbMigrationsService']);
39 | }
40 |
41 | /**
42 | * Extends the database driver service
43 | *
44 | * @param DatabaseDriver $db The database driver to extend.
45 | * @param Container $container The DI container.
46 | *
47 | * @return DatabaseDriver
48 | */
49 | public function extendDatabaseDriverService(DatabaseDriver $db, Container $container): DatabaseDriver
50 | {
51 | $db->setMonitor($container->get(LoggingMonitor::class));
52 |
53 | return $db;
54 | }
55 |
56 | /**
57 | * Get the `db.migrations` service
58 | *
59 | * @param Container $container The DI container.
60 | *
61 | * @return Migrations
62 | */
63 | public function getDbMigrationsService(Container $container): Migrations
64 | {
65 | return new Migrations(
66 | $container->get(DatabaseDriver::class),
67 | $container->get('filesystem.migrations')
68 | );
69 | }
70 |
71 | /**
72 | * Get the `db.monitor.logging` service
73 | *
74 | * @param Container $container The DI container.
75 | *
76 | * @return LoggingMonitor
77 | */
78 | public function getDbMonitorLoggingService(Container $container): LoggingMonitor
79 | {
80 | $monitor = new LoggingMonitor();
81 | $monitor->setLogger($container->get('monolog.logger.database'));
82 |
83 | return $monitor;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Providers/EventServiceProvider.php:
--------------------------------------------------------------------------------
1 | alias(Dispatcher::class, DispatcherInterface::class)
36 | ->share(DispatcherInterface::class, [$this, 'getDispatcherService']);
37 |
38 | $container->share(AnalyticsSubscriber::class, [$this, 'getAnalyticsSubscriberService'])
39 | ->tag('event.subscriber', [AnalyticsSubscriber::class]);
40 |
41 | $container->share(ErrorSubscriber::class, [$this, 'getErrorSubscriberService'])
42 | ->tag('event.subscriber', [ErrorSubscriber::class]);
43 | }
44 |
45 | /**
46 | * Get the AnalyticsSubscriber service
47 | *
48 | * @param Container $container The DI container.
49 | *
50 | * @return AnalyticsSubscriber
51 | */
52 | public function getAnalyticsSubscriberService(Container $container): AnalyticsSubscriber
53 | {
54 | $subscriber = new AnalyticsSubscriber($container->get(Analytics::class));
55 | $subscriber->setLogger($container->get(LoggerInterface::class));
56 |
57 | return $subscriber;
58 | }
59 |
60 | /**
61 | * Get the DispatcherInterface service
62 | *
63 | * @param Container $container The DI container.
64 | *
65 | * @return DispatcherInterface
66 | */
67 | public function getDispatcherService(Container $container): DispatcherInterface
68 | {
69 | $dispatcher = new Dispatcher();
70 |
71 | foreach ($container->getTagged('event.subscriber') as $subscriber) {
72 | $dispatcher->addSubscriber($subscriber);
73 | }
74 |
75 | return $dispatcher;
76 | }
77 |
78 | /**
79 | * Get the ErrorSubscriber service
80 | *
81 | * @param Container $container The DI container.
82 | *
83 | * @return ErrorSubscriber
84 | */
85 | public function getErrorSubscriberService(Container $container): ErrorSubscriber
86 | {
87 | $subscriber = new ErrorSubscriber();
88 | $subscriber->setLogger($container->get(LoggerInterface::class));
89 |
90 | return $subscriber;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/Providers/FlysystemServiceProvider.php:
--------------------------------------------------------------------------------
1 | share('filesystem.migrations', [$this, 'getMigrationsFilesystemService']);
32 | $container->share('filesystem.snapshot', [$this, 'getSnapshotFilesystemService']);
33 | $container->share('filesystem.versions', [$this, 'getVersionsFilesystemService']);
34 | }
35 |
36 | /**
37 | * Get the `filesystem.migrations` service
38 | *
39 | * @param Container $container The DI container.
40 | *
41 | * @return Filesystem
42 | */
43 | public function getMigrationsFilesystemService(Container $container): Filesystem
44 | {
45 | return new Filesystem(new Local(APPROOT . '/etc/migrations'));
46 | }
47 |
48 | /**
49 | * Get the `filesystem.snapshot` service
50 | *
51 | * @param Container $container The DI container.
52 | *
53 | * @return Filesystem
54 | */
55 | public function getSnapshotFilesystemService(Container $container): Filesystem
56 | {
57 | return new Filesystem(new Local(APPROOT . '/snapshots'));
58 | }
59 |
60 | /**
61 | * Get the `filesystem.versions` service
62 | *
63 | * @param Container $container The DI container.
64 | *
65 | * @return Filesystem
66 | */
67 | public function getVersionsFilesystemService(Container $container): Filesystem
68 | {
69 | return new Filesystem(new Local(APPROOT . '/versions'));
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/Providers/GitHubServiceProvider.php:
--------------------------------------------------------------------------------
1 | alias('github', BaseGithub::class)
32 | ->alias(GitHub::class, BaseGithub::class)
33 | ->share(BaseGithub::class, [$this, 'getGithubService']);
34 | }
35 |
36 | /**
37 | * Get the `github` service
38 | *
39 | * @param Container $container The DI container.
40 | *
41 | * @return GitHub
42 | */
43 | public function getGithubService(Container $container): GitHub
44 | {
45 | /** @var \Joomla\Registry\Registry $config */
46 | $config = $container->get('config');
47 |
48 | return new GitHub($config->extract('github'));
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Providers/MonologServiceProvider.php:
--------------------------------------------------------------------------------
1 | share('monolog.processor.psr3', [$this, 'getMonologProcessorPsr3Service']);
35 |
36 | // Register the web processor
37 | $container->share('monolog.processor.web', [$this, 'getMonologProcessorWebService']);
38 |
39 | // Register the web application handler
40 | $container->share('monolog.handler.application', [$this, 'getMonologHandlerApplicationService']);
41 |
42 | // Register the database handler
43 | $container->share('monolog.handler.database', [$this, 'getMonologHandlerDatabaseService']);
44 |
45 | // Register the web application Logger
46 | $container->share('monolog.logger.application', [$this, 'getMonologLoggerApplicationService']);
47 |
48 | // Register the CLI application Logger
49 | $container->share('monolog.logger.cli', [$this, 'getMonologLoggerCliService']);
50 |
51 | // Register the database Logger
52 | $container->share('monolog.logger.database', [$this, 'getMonologLoggerDatabaseService']);
53 | }
54 |
55 | /**
56 | * Get the `monolog.processor.psr3` service
57 | *
58 | * @param Container $container The DI container.
59 | *
60 | * @return PsrLogMessageProcessor
61 | */
62 | public function getMonologProcessorPsr3Service(Container $container): PsrLogMessageProcessor
63 | {
64 | return new PsrLogMessageProcessor();
65 | }
66 |
67 | /**
68 | * Get the `monolog.processor.web` service
69 | *
70 | * @param Container $container The DI container.
71 | *
72 | * @return WebProcessor
73 | */
74 | public function getMonologProcessorWebService(Container $container): WebProcessor
75 | {
76 | return new WebProcessor();
77 | }
78 |
79 | /**
80 | * Get the `monolog.handler.application` service
81 | *
82 | * @param Container $container The DI container.
83 | *
84 | * @return StreamHandler
85 | */
86 | public function getMonologHandlerApplicationService(Container $container): StreamHandler
87 | {
88 | /** @var \Joomla\Registry\Registry $config */
89 | $config = $container->get('config');
90 |
91 | $level = strtoupper($config->get('log.application', $config->get('log.level', 'error')));
92 |
93 | return new StreamHandler(APPROOT . '/logs/stats.log', \constant('\\Monolog\\Logger::' . $level));
94 | }
95 |
96 | /**
97 | * Get the `monolog.handler.database` service
98 | *
99 | * @param Container $container The DI container.
100 | *
101 | * @return StreamHandler
102 | */
103 | public function getMonologHandlerDatabaseService(Container $container): StreamHandler
104 | {
105 | /** @var \Joomla\Registry\Registry $config */
106 | $config = $container->get('config');
107 |
108 | $level = strtoupper($config->get('log.database', $config->get('log.level', 'error')));
109 |
110 | return new StreamHandler(APPROOT . '/logs/stats.log', \constant('\\Monolog\\Logger::' . $level));
111 | }
112 |
113 | /**
114 | * Get the `monolog.logger.application` service
115 | *
116 | * @param Container $container The DI container.
117 | *
118 | * @return Logger
119 | */
120 | public function getMonologLoggerApplicationService(Container $container): Logger
121 | {
122 | return new Logger(
123 | 'Application',
124 | [
125 | $container->get('monolog.handler.application'),
126 | ],
127 | [
128 | $container->get('monolog.processor.web'),
129 | ]
130 | );
131 | }
132 |
133 | /**
134 | * Get the `monolog.logger.cli` service
135 | *
136 | * @param Container $container The DI container.
137 | *
138 | * @return Logger
139 | */
140 | public function getMonologLoggerCliService(Container $container): Logger
141 | {
142 | return new Logger(
143 | 'Application',
144 | [
145 | $container->get('monolog.handler.application'),
146 | ]
147 | );
148 | }
149 |
150 | /**
151 | * Get the `monolog.logger.database` service
152 | *
153 | * @param Container $container The DI container.
154 | *
155 | * @return Logger
156 | */
157 | public function getMonologLoggerDatabaseService(Container $container): Logger
158 | {
159 | return new Logger(
160 | 'Application',
161 | [
162 | $container->get('monolog.handler.database'),
163 | ],
164 | [
165 | $container->get('monolog.processor.psr3'),
166 | $container->get('monolog.processor.web'),
167 | ]
168 | );
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/src/Providers/RepositoryServiceProvider.php:
--------------------------------------------------------------------------------
1 | share(StatisticsRepository::class, [$this, 'getStatisticsRepositoryService']);
34 | $container->share(InfluxdbRepository::class, [$this, 'getInfluxdbRepositoryService']);
35 | }
36 |
37 | /**
38 | * Get the StatisticsRepository service
39 | *
40 | * @param Container $container The DI container.
41 | *
42 | * @return StatisticsRepository
43 | */
44 | public function getStatisticsRepositoryService(Container $container): StatisticsRepository
45 | {
46 | return new StatisticsRepository(
47 | $container->get(DatabaseInterface::class)
48 | );
49 | }
50 |
51 | /**
52 | * Get the StatisticsRepository service
53 | *
54 | * @param Container $container The DI container.
55 | *
56 | * @return StatisticsRepository
57 | */
58 | public function getInfluxdbRepositoryService(Container $container): InfluxdbRepository
59 | {
60 | return new InfluxdbRepository(
61 | $container->get(Client::class)
62 | );
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Providers/WebApplicationServiceProvider.php:
--------------------------------------------------------------------------------
1 | alias(WebApplication::class, AbstractWebApplication::class)
46 | ->share(AbstractWebApplication::class, [$this, 'getWebApplicationService']);
47 |
48 | /*
49 | * Application Class Dependencies
50 | */
51 |
52 | $container->share(Input::class, [$this, 'getInputService']);
53 | $container->share(Router::class, [$this, 'getRouterService']);
54 |
55 | $container->alias(ContainerControllerResolver::class, ControllerResolverInterface::class)
56 | ->share(ControllerResolverInterface::class, [$this, 'getControllerResolverService']);
57 |
58 | $container->share(WebClient::class, [$this, 'getWebClientService']);
59 |
60 | /*
61 | * MVC Layer
62 | */
63 |
64 | // Controllers
65 | $container->share(DisplayStatisticsController::class, [$this, 'getDisplayStatisticsControllerService']);
66 | $container->share(SubmitDataController::class, [$this, 'getSubmitDataControllerService']);
67 |
68 | // Views
69 | $container->share(StatsJsonView::class, [$this, 'getStatsJsonViewService']);
70 | }
71 |
72 | /**
73 | * Get the controller resolver service
74 | *
75 | * @param Container $container The DI container.
76 | *
77 | * @return ControllerResolverInterface
78 | */
79 | public function getControllerResolverService(Container $container): ControllerResolverInterface
80 | {
81 | return new ContainerControllerResolver($container);
82 | }
83 |
84 | /**
85 | * Get the DisplayControllerGet class service
86 | *
87 | * @param Container $container The DI container.
88 | *
89 | * @return DisplayStatisticsController
90 | */
91 | public function getDisplayStatisticsControllerService(Container $container): DisplayStatisticsController
92 | {
93 | $controller = new DisplayStatisticsController(
94 | $container->get(StatsJsonView::class)
95 | );
96 |
97 | $controller->setApplication($container->get(AbstractApplication::class));
98 | $controller->setInput($container->get(Input::class));
99 |
100 | return $controller;
101 | }
102 |
103 | /**
104 | * Get the Input class service
105 | *
106 | * @param Container $container The DI container.
107 | *
108 | * @return Input
109 | */
110 | public function getInputService(Container $container): Input
111 | {
112 | return new Input($_REQUEST);
113 | }
114 |
115 | /**
116 | * Get the router service
117 | *
118 | * @param Container $container The DI container.
119 | *
120 | * @return Router
121 | */
122 | public function getRouterService(Container $container): Router
123 | {
124 | $router = new Router();
125 |
126 | $router->get(
127 | '/',
128 | DisplayStatisticsController::class
129 | );
130 |
131 | $router->post(
132 | '/submit',
133 | SubmitDataController::class
134 | );
135 |
136 | $router->get(
137 | '/:source',
138 | DisplayStatisticsController::class,
139 | [
140 | 'source' => '(' . implode('|', StatisticsRepository::ALLOWED_SOURCES) . ')',
141 | ]
142 | );
143 |
144 | return $router;
145 | }
146 |
147 | /**
148 | * Get the StatsJsonView class service
149 | *
150 | * @param Container $container The DI container.
151 | *
152 | * @return StatsJsonView
153 | */
154 | public function getStatsJsonViewService(Container $container): StatsJsonView
155 | {
156 | return new StatsJsonView(
157 | $container->get(StatisticsRepository::class)
158 | );
159 | }
160 |
161 | /**
162 | * Get the SubmitControllerCreate class service
163 | *
164 | * @param Container $container The DI container.
165 | *
166 | * @return SubmitDataController
167 | */
168 | public function getSubmitDataControllerService(Container $container): SubmitDataController
169 | {
170 | $controller = new SubmitDataController(
171 | $container->get(StatisticsRepository::class),
172 | $container->get('filesystem.versions'),
173 | $container->get(InfluxdbRepository::class),
174 | );
175 |
176 | $controller->setApplication($container->get(AbstractApplication::class));
177 | $controller->setInput($container->get(Input::class));
178 |
179 | return $controller;
180 | }
181 |
182 | /**
183 | * Get the web application service
184 | *
185 | * @param Container $container The DI container.
186 | *
187 | * @return WebApplication
188 | */
189 | public function getWebApplicationService(Container $container): WebApplication
190 | {
191 | $application = new WebApplication(
192 | $container->get(ControllerResolverInterface::class),
193 | $container->get(Router::class),
194 | $container->get(Input::class),
195 | $container->get('config'),
196 | $container->get(WebClient::class),
197 | new JsonResponse([])
198 | );
199 |
200 | // Inject extra services
201 | $application->setDispatcher($container->get(DispatcherInterface::class));
202 | $application->setLogger($container->get(LoggerInterface::class));
203 |
204 | return $application;
205 | }
206 |
207 | /**
208 | * Get the web client service
209 | *
210 | * @param Container $container The DI container.
211 | *
212 | * @return WebClient
213 | */
214 | public function getWebClientService(Container $container): WebClient
215 | {
216 | /** @var Input $input */
217 | $input = $container->get(Input::class);
218 | $userAgent = $input->server->getString('HTTP_USER_AGENT', '');
219 | $acceptEncoding = $input->server->getString('HTTP_ACCEPT_ENCODING', '');
220 | $acceptLanguage = $input->server->getString('HTTP_ACCEPT_LANGUAGE', '');
221 |
222 | return new WebClient($userAgent, $acceptEncoding, $acceptLanguage);
223 | }
224 | }
225 |
--------------------------------------------------------------------------------
/src/Repositories/InfluxdbRepository.php:
--------------------------------------------------------------------------------
1 | db = $db;
39 | }
40 |
41 | /**
42 | * Saves the given data.
43 | *
44 | * @param \stdClass $data Data object to save.
45 | *
46 | * @return void
47 | */
48 | public function save(\stdClass $data): void
49 | {
50 | $writeApi = $this->db->createWriteApi();
51 |
52 | // Set the modified date of the record
53 | $timestamp = (new \DateTime('now', new \DateTimeZone('UTC')))->getTimestamp();
54 |
55 | $point = Point::measurement('joomla')
56 | ->addTag('unique_id', $data->unique_id)
57 | ->time($timestamp);
58 |
59 | // Extract major and minor version
60 | if (!empty($data->php_version)) {
61 | preg_match('/^(\d+)\.(\d+)\./', $data->php_version, $phpVersion);
62 | $point->addField('php_version', $data->php_version);
63 | if (!empty($phpVersion)) {
64 | $point->addField('php_major', $phpVersion[1])
65 | ->addField('php_minor', $phpVersion[1] . '.' . $phpVersion[2]);
66 | }
67 | }
68 |
69 | // Prepare CMS version
70 | if (!empty($data->cms_version)) {
71 | preg_match('/^(\d+)\.(\d+)\./', $data->cms_version, $cmsVersions);
72 |
73 | $point->addField('cms_version', $data->cms_version);
74 | if (!empty($cmsVersions)) {
75 | $point
76 | ->addField('cms_major', $cmsVersions[1])
77 | ->addField('cms_minor', $cmsVersions[1] . '.' . $cmsVersions[2]);
78 | }
79 | }
80 |
81 | // Prepare Database versions
82 | if (!empty($data->db_version)) {
83 | preg_match('/^(\d+)\.(\d+)\./', $data->db_version, $dbVersions);
84 |
85 | $point->addField('db_version', $data->db_version);
86 | if (!empty($dbVersions)) {
87 | $point->addField('db_major', $dbVersions[1])
88 | ->addField('db_minor', $dbVersions[1] . '.' . $dbVersions[2]);
89 | }
90 | }
91 |
92 | // Prepare Database Driver
93 | if (!empty($data->db_type)) {
94 | $dbServer = null;
95 | if ($data->db_type === 'postgresql') {
96 | $dbServer = 'PostgreSQL';
97 | } elseif (str_contains($data->db_type, 'mysql')) {
98 | $dbServer = 'MySQL';
99 | if (!empty($data->db_version)) {
100 | if (
101 | version_compare($data->db_version, '10.0.0', '>=')
102 | // We know this is not 100% correct but more accurate than expecting MySQL with this version string
103 | || version_compare($data->db_version, '5.5.5', '=')
104 | ) {
105 | $dbServer = 'MariaDB';
106 | }
107 | }
108 | } elseif (str_contains($data->db_type, 'mariadb')) {
109 | $dbServer = 'MariaDB';
110 | } elseif (str_contains($data->db_type, 'sqlsrv')) {
111 | $dbServer = 'MSSQL';
112 | }
113 |
114 | $point->addField('db_driver', $data->db_type);
115 | if (!empty($dbServer)) {
116 | $point->addField('db_server', $dbServer);
117 | }
118 | }
119 |
120 | // Prepare Operating System
121 | if (!empty($data->server_os)) {
122 | $os = explode(' ', $data->server_os, 2);
123 |
124 | $point->addField('server_string', $data->server_os);
125 | if (!empty($os[0])) {
126 | $point->addField('server_os', $os[0]);
127 | }
128 | }
129 |
130 | $writeApi->write($point, \InfluxDB2\Model\WritePrecision::S, 'cms');
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/Repositories/StatisticsRepository.php:
--------------------------------------------------------------------------------
1 | db = $db;
43 | }
44 |
45 | /**
46 | * Loads the statistics data from the database.
47 | *
48 | * @param string $column A single column to filter on
49 | *
50 | * @return array An array containing the response data
51 | *
52 | * @throws \InvalidArgumentException
53 | */
54 | public function getItems(string $column = ''): array
55 | {
56 | // Validate the requested column is actually in the table
57 | if ($column !== '') {
58 | // The column should exist in the table and be part of the API
59 | if (!\in_array($column, self::ALLOWED_SOURCES)) {
60 | throw new \InvalidArgumentException('An invalid data source was requested.', 404);
61 | }
62 |
63 | return $this->db->setQuery(
64 | $this->db->getQuery(true)
65 | ->select('*')
66 | ->from($this->db->quoteName('#__jstats_counter_' . $column))
67 | )->loadAssocList();
68 | }
69 |
70 | $return = [];
71 |
72 | foreach (self::ALLOWED_SOURCES as $column) {
73 | $return[$column] = $this->db->setQuery(
74 | $this->db->getQuery(true)
75 | ->select('*')
76 | ->from($this->db->quoteName('#__jstats_counter_' . $column))
77 | )->loadAssocList();
78 | }
79 |
80 | return $return;
81 | }
82 |
83 | /**
84 | * Loads the recently updated statistics data from the database.
85 | *
86 | * Recently updated is an arbitrary 90 days, submit a pull request for a different behavior.
87 | *
88 | * @return array An array containing the response data
89 | */
90 | public function getRecentlyUpdatedItems(): array
91 | {
92 | $return = [];
93 | $db = $this->db;
94 |
95 | foreach (self::ALLOWED_SOURCES as $column) {
96 | if (($column !== 'cms_php_version') && ($column !== 'db_type_version')) {
97 | $return[$column] = $this->db->setQuery(
98 | $this->db->getQuery(true)
99 | ->select($column)
100 | ->select('COUNT(' . $column . ') AS count')
101 | ->from($this->db->quoteName('#__jstats'))
102 | ->where('modified BETWEEN DATE_SUB(NOW(), INTERVAL 90 DAY) AND NOW()')
103 | ->group($column)
104 | )->loadAssocList();
105 | continue;
106 | }
107 |
108 | if ($column === 'cms_php_version') {
109 | $return['cms_php_version'] = $this->db->setQuery(
110 | $this->db->getQuery(true)
111 | ->select('CONCAT(' . $db->qn('cms_version') . ', ' . $db->q(' - ') . ', ' . $db->qn('php_version') . ') AS cms_php_version')
112 | ->select('COUNT(*) AS count')
113 | ->from($this->db->quoteName('#__jstats'))
114 | ->where('modified BETWEEN DATE_SUB(NOW(), INTERVAL 90 DAY) AND NOW()')
115 | ->group('CONCAT(' . $db->qn('cms_version') . ', ' . $db->q(' - ') . ', ' . $db->qn('php_version') . ')')
116 | )->loadAssocList();
117 | continue;
118 | }
119 |
120 | if ($column === 'db_type_version') {
121 | $return['db_type_version'] = $this->db->setQuery(
122 | $this->db->getQuery(true)
123 | ->select('CONCAT(' . $db->qn('db_type') . ', ' . $db->q(' - ') . ', ' . $db->qn('db_version') . ') AS db_type_version')
124 | ->select('COUNT(*) AS count')
125 | ->from($this->db->quoteName('#__jstats'))
126 | ->where('modified BETWEEN DATE_SUB(NOW(), INTERVAL 90 DAY) AND NOW()')
127 | ->group('CONCAT(' . $db->qn('db_type') . ', ' . $db->q(' - ') . ', ' . $db->qn('db_version') . ')')
128 | )->loadAssocList();
129 | continue;
130 | }
131 | }
132 |
133 | return $return;
134 | }
135 |
136 | /**
137 | * Loads the recently updated statistics data from the database.
138 | *
139 | * Updated within a timeframe, submit a pull request for a different behavior.
140 | *
141 | * @param int $timeframe The timeframe in days to consider
142 | * @param string $showColumn The column to return
143 | *
144 | * @return array An array containing the response data
145 | */
146 | public function getTimeframeUpdatedItems(int $timeframe = 0, string $showColumn = ''): array
147 | {
148 | $return = [];
149 | $columns = self::ALLOWED_SOURCES;
150 |
151 | if ($showColumn !== '') {
152 | // The column should exist in the table and be part of the API
153 | if (!\in_array($showColumn, self::ALLOWED_SOURCES)) {
154 | throw new \InvalidArgumentException('An invalid data source was requested.', 404);
155 | }
156 |
157 | $columns = [$showColumn];
158 | }
159 |
160 | foreach ($columns as $column) {
161 | if (\in_array($column, ['cms_php_version', 'db_type_version'])) {
162 | continue;
163 | }
164 |
165 | $return[$column] = $this->db->setQuery(
166 | $this->db->getQuery(true)
167 | ->select($column)
168 | ->select('COUNT(' . $column . ') AS count')
169 | ->from('(SELECT * FROM ' . $this->db->quoteName('#__jstats')
170 | . ' WHERE modified > DATE_SUB(NOW(), INTERVAL ' . $this->db->quote($timeframe) . ' DAY)) AS tmptable')
171 | ->group($column)
172 | )->loadAssocList();
173 | }
174 |
175 | if ($showColumn !== '') {
176 | return $return[$showColumn];
177 | }
178 |
179 | return $return;
180 | }
181 | /**
182 | * Saves the given data.
183 | *
184 | * @param \stdClass $data Data object to save.
185 | *
186 | * @return void
187 | */
188 | public function save(\stdClass $data): void
189 | {
190 | // Set the modified date of the record
191 | $data->modified = (new \DateTime('now', new \DateTimeZone('UTC')))->format($this->db->getDateFormat());
192 |
193 | // Check if a row exists for this unique ID and update the existing record if so
194 | $recordExists = $this->db->setQuery(
195 | $this->db->getQuery(true)
196 | ->select('unique_id')
197 | ->from('#__jstats')
198 | ->where('unique_id = :unique_id')
199 | ->bind(':unique_id', $data->unique_id, ParameterType::STRING)
200 | )->loadResult();
201 |
202 | if ($recordExists) {
203 | $this->db->updateObject('#__jstats', $data, ['unique_id']);
204 | } else {
205 | $this->db->insertObject('#__jstats', $data, ['unique_id']);
206 | }
207 | }
208 | }
209 |
--------------------------------------------------------------------------------
/src/WebApplication.php:
--------------------------------------------------------------------------------
1 | controllerResolver = $controllerResolver;
68 | $this->router = $router;
69 |
70 | // Call the constructor as late as possible (it runs `initialise`).
71 | parent::__construct($input, $config, $client, $response);
72 | }
73 |
74 | /**
75 | * Method to run the application routines.
76 | *
77 | * @return void
78 | *
79 | * @since __DEPLOY_VERSION__
80 | */
81 | protected function doExecute(): void
82 | {
83 | $route = $this->router->parseRoute($this->get('uri.route'), $this->input->getMethod());
84 |
85 | // Add variables to the input if not already set
86 | foreach ($route->getRouteVariables() as $key => $value) {
87 | $this->input->def($key, $value);
88 | }
89 |
90 | \call_user_func($this->controllerResolver->resolve($route));
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/tests/Commands/Database/MigrateCommandTest.php:
--------------------------------------------------------------------------------
1 | createMock(Migrations::class);
33 | $migrations->expects($this->once())
34 | ->method('migrateDatabase');
35 |
36 | $logger = new TestLogger;
37 |
38 | $input = new ArrayInput(
39 | [
40 | 'command' => 'database:migrate',
41 | ]
42 | );
43 | $output = new BufferedOutput;
44 |
45 | $application = new Application($input, $output);
46 |
47 | $command = new MigrateCommand($migrations);
48 | $command->setApplication($application);
49 | $command->setLogger($logger);
50 |
51 | $this->assertSame(0, $command->execute($input, $output));
52 |
53 | $screenOutput = $output->fetch();
54 |
55 | $this->assertStringContainsString('Database migrated to latest version.', $screenOutput);
56 | }
57 |
58 | /**
59 | * @testdox The command executes the given database migration
60 | */
61 | public function testTheCommandExecutesTheGivenDatabaseMigration(): void
62 | {
63 | /** @var MockObject|Migrations $migrations */
64 | $migrations = $this->createMock(Migrations::class);
65 | $migrations->expects($this->once())
66 | ->method('migrateDatabase')
67 | ->with('abc123');
68 |
69 | $logger = new TestLogger;
70 |
71 | $input = new ArrayInput(
72 | [
73 | 'command' => 'database:migrate',
74 | '--mversion' => 'abc123',
75 | ]
76 | );
77 | $output = new BufferedOutput;
78 |
79 | $application = new Application($input, $output);
80 |
81 | $command = new MigrateCommand($migrations);
82 | $command->setApplication($application);
83 | $command->setLogger($logger);
84 |
85 | $this->assertSame(0, $command->execute($input, $output));
86 |
87 | $screenOutput = $output->fetch();
88 |
89 | $this->assertStringContainsString('Database migrated to version "abc123".', $screenOutput);
90 | }
91 |
92 | /**
93 | * @testdox The command handles migration errors
94 | */
95 | public function testTheCommandHandlesMigrationErrors(): void
96 | {
97 | /** @var MockObject|Migrations $migrations */
98 | $migrations = $this->createMock(Migrations::class);
99 | $migrations->expects($this->once())
100 | ->method('migrateDatabase')
101 | ->willThrowException(new FileNotFoundException('abc123.sql'));
102 |
103 | $logger = new TestLogger;
104 |
105 | $input = new ArrayInput(
106 | [
107 | 'command' => 'database:migrate',
108 | ]
109 | );
110 | $output = new BufferedOutput;
111 |
112 | $application = new Application($input, $output);
113 |
114 | $command = new MigrateCommand($migrations);
115 | $command->setApplication($application);
116 | $command->setLogger($logger);
117 |
118 | $this->assertSame(1, $command->execute($input, $output));
119 |
120 | $screenOutput = $output->fetch();
121 |
122 | $this->assertStringContainsString('Error migrating database: File not found at path: abc123.sql', $screenOutput);
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/tests/Commands/Database/MigrationStatusCommandTest.php:
--------------------------------------------------------------------------------
1 | tableExists = false;
32 |
33 | /** @var MockObject|Migrations $migrations */
34 | $migrations = $this->createMock(Migrations::class);
35 | $migrations->expects($this->once())
36 | ->method('checkStatus')
37 | ->willReturn($status);
38 |
39 | $input = new ArrayInput(
40 | [
41 | 'command' => 'database:migrations:status',
42 | ]
43 | );
44 | $output = new BufferedOutput;
45 |
46 | $application = new Application($input, $output);
47 |
48 | $command = new MigrationStatusCommand($migrations);
49 | $command->setApplication($application);
50 |
51 | $this->assertSame(0, $command->execute($input, $output));
52 |
53 | $screenOutput = $output->fetch();
54 |
55 | $this->assertStringContainsString('The migrations table does not exist', $screenOutput);
56 | }
57 |
58 | /**
59 | * @testdox The command shows the status when on the latest version
60 | */
61 | public function testTheCommandShowsTheStatusWhenOnTheLatestVersion(): void
62 | {
63 | $status = new MigrationsStatus;
64 | $status->latest = true;
65 |
66 | /** @var MockObject|Migrations $migrations */
67 | $migrations = $this->createMock(Migrations::class);
68 | $migrations->expects($this->once())
69 | ->method('checkStatus')
70 | ->willReturn($status);
71 |
72 | $input = new ArrayInput(
73 | [
74 | 'command' => 'database:migrations:status',
75 | ]
76 | );
77 | $output = new BufferedOutput;
78 |
79 | $application = new Application($input, $output);
80 |
81 | $command = new MigrationStatusCommand($migrations);
82 | $command->setApplication($application);
83 |
84 | $this->assertSame(0, $command->execute($input, $output));
85 |
86 | $screenOutput = $output->fetch();
87 |
88 | $this->assertStringContainsString('Your database is up-to-date.', $screenOutput);
89 | }
90 |
91 | /**
92 | * @testdox The command shows the status when not on the latest version
93 | */
94 | public function testTheCommandShowsTheStatusWhenNotOnTheLatestVersion(): void
95 | {
96 | $status = new MigrationsStatus;
97 | $status->currentVersion = '1';
98 | $status->latest = false;
99 | $status->latestVersion = '2';
100 | $status->missingMigrations = 1;
101 |
102 | /** @var MockObject|Migrations $migrations */
103 | $migrations = $this->createMock(Migrations::class);
104 | $migrations->expects($this->once())
105 | ->method('checkStatus')
106 | ->willReturn($status);
107 |
108 | $input = new ArrayInput(
109 | [
110 | 'command' => 'database:migrations:status',
111 | ]
112 | );
113 | $output = new BufferedOutput;
114 |
115 | $application = new Application($input, $output);
116 |
117 | $command = new MigrationStatusCommand($migrations);
118 | $command->setApplication($application);
119 |
120 | $this->assertSame(0, $command->execute($input, $output));
121 |
122 | $screenOutput = $output->fetch();
123 |
124 | $this->assertStringContainsString('Your database is not up-to-date. You are missing 1 migration(s).', $screenOutput);
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/tests/Commands/SnapshotCommandTest.php:
--------------------------------------------------------------------------------
1 | [
32 | [
33 | 'cms_version' => '3.5.0',
34 | 'count' => 3,
35 | ],
36 | ],
37 | 'php_version' => [
38 | [
39 | 'php_version' => PHP_VERSION,
40 | 'count' => 3,
41 | ],
42 | ],
43 | 'db_type' => [
44 | [
45 | 'db_type' => 'mysql',
46 | 'count' => 1,
47 | ],
48 | [
49 | 'db_type' => 'postgresql',
50 | 'count' => 1,
51 | ],
52 | [
53 | 'db_type' => 'sqlsrv',
54 | 'count' => 1,
55 | ],
56 | ],
57 | 'db_version' => [
58 | [
59 | 'db_version' => '5.6.25',
60 | 'count' => 1,
61 | ],
62 | [
63 | 'db_version' => '9.4.0',
64 | 'count' => 1,
65 | ],
66 | [
67 | 'db_version' => '10.50.2500',
68 | 'count' => 1,
69 | ],
70 | ],
71 | 'server_os' => [
72 | [
73 | 'server_os' => 'Darwin 14.1.0',
74 | 'count' => 2,
75 | ],
76 | [
77 | 'server_os' => '',
78 | 'count' => 1,
79 | ],
80 | ],
81 | ];
82 |
83 | /**
84 | * @testdox The command creates a full snapshot
85 | */
86 | public function testTheCommandCreatesAFullSnapshot(): void
87 | {
88 | /** @var MockObject|StatsJsonView $view */
89 | $view = $this->createMock(StatsJsonView::class);
90 | $view->expects($this->once())
91 | ->method('isAuthorizedRaw')
92 | ->with(true);
93 |
94 | $view->expects($this->once())
95 | ->method('render')
96 | ->willReturn(json_encode(self::STATS_DATA));
97 |
98 | $adapter = new MemoryAdapter;
99 |
100 | $filesystem = new Filesystem($adapter);
101 |
102 | $input = new ArrayInput(
103 | [
104 | 'command' => 'snapshot',
105 | ]
106 | );
107 | $output = new BufferedOutput;
108 |
109 | $application = new Application($input, $output);
110 |
111 | $command = new SnapshotCommand($view, $filesystem);
112 | $command->setApplication($application);
113 |
114 | $this->assertSame(0, $command->execute($input, $output));
115 |
116 | $screenOutput = $output->fetch();
117 |
118 | $this->assertStringContainsString('Snapshot recorded.', $screenOutput);
119 | $this->assertCount(1, $filesystem->listContents());
120 | }
121 |
122 | /**
123 | * @testdox The command creates a full snapshot for a single source
124 | */
125 | public function testTheCommandCreatesAFullSnapshotForASingleSource(): void
126 | {
127 | $source = 'db_type';
128 |
129 | /** @var MockObject|StatsJsonView $view */
130 | $view = $this->createMock(StatsJsonView::class);
131 | $view->expects($this->once())
132 | ->method('isAuthorizedRaw')
133 | ->with(true);
134 |
135 | $view->expects($this->once())
136 | ->method('setSource')
137 | ->with($source);
138 |
139 | $view->expects($this->once())
140 | ->method('render')
141 | ->willReturn(json_encode(self::STATS_DATA[$source]));
142 |
143 | $adapter = new MemoryAdapter;
144 |
145 | $filesystem = new Filesystem($adapter);
146 |
147 | $input = new ArrayInput(
148 | [
149 | 'command' => 'snapshot',
150 | '--source' => $source,
151 | ]
152 | );
153 | $output = new BufferedOutput;
154 |
155 | $application = new Application($input, $output);
156 |
157 | $command = new SnapshotCommand($view, $filesystem);
158 | $command->setApplication($application);
159 |
160 | $this->assertSame(0, $command->execute($input, $output));
161 |
162 | $screenOutput = $output->fetch();
163 |
164 | $this->assertStringContainsString('Snapshot recorded.', $screenOutput);
165 | $this->assertCount(1, $filesystem->listContents());
166 | }
167 |
168 | /**
169 | * @testdox The command does not create a snapshot for an invalid source
170 | */
171 | public function testTheCommandDoesNotCreateASnapshotForAnInvalidSource(): void
172 | {
173 | $this->expectException(InvalidOptionException::class);
174 |
175 | $source = 'bad';
176 |
177 | /** @var MockObject|StatsJsonView $view */
178 | $view = $this->createMock(StatsJsonView::class);
179 | $view->expects($this->once())
180 | ->method('isAuthorizedRaw')
181 | ->with(true);
182 |
183 | $view->expects($this->never())
184 | ->method('setSource');
185 |
186 | $view->expects($this->never())
187 | ->method('render');
188 |
189 | $adapter = new MemoryAdapter;
190 |
191 | $filesystem = new Filesystem($adapter);
192 |
193 | $input = new ArrayInput(
194 | [
195 | 'command' => 'snapshot',
196 | '--source' => $source,
197 | ]
198 | );
199 | $output = new BufferedOutput;
200 |
201 | $application = new Application($input, $output);
202 |
203 | $command = new SnapshotCommand($view, $filesystem);
204 | $command->setApplication($application);
205 |
206 | $command->execute($input, $output);
207 | }
208 | }
209 |
--------------------------------------------------------------------------------
/tests/Commands/SnapshotRecentlyUpdatedCommandTest.php:
--------------------------------------------------------------------------------
1 | [
32 | [
33 | 'cms_version' => '3.5.0',
34 | 'count' => 3,
35 | ],
36 | ],
37 | 'php_version' => [
38 | [
39 | 'php_version' => PHP_VERSION,
40 | 'count' => 3,
41 | ],
42 | ],
43 | 'db_type' => [
44 | [
45 | 'db_type' => 'mysql',
46 | 'count' => 1,
47 | ],
48 | [
49 | 'db_type' => 'postgresql',
50 | 'count' => 1,
51 | ],
52 | [
53 | 'db_type' => 'sqlsrv',
54 | 'count' => 1,
55 | ],
56 | ],
57 | 'db_version' => [
58 | [
59 | 'db_version' => '5.6.25',
60 | 'count' => 1,
61 | ],
62 | [
63 | 'db_version' => '9.4.0',
64 | 'count' => 1,
65 | ],
66 | [
67 | 'db_version' => '10.50.2500',
68 | 'count' => 1,
69 | ],
70 | ],
71 | 'server_os' => [
72 | [
73 | 'server_os' => 'Darwin 14.1.0',
74 | 'count' => 2,
75 | ],
76 | [
77 | 'server_os' => '',
78 | 'count' => 1,
79 | ],
80 | ],
81 | ];
82 |
83 | /**
84 | * @testdox The command creates a full snapshot
85 | */
86 | public function testTheCommandCreatesAFullSnapshot(): void
87 | {
88 | /** @var MockObject|StatsJsonView $view */
89 | $view = $this->createMock(StatsJsonView::class);
90 | $view->expects($this->once())
91 | ->method('isAuthorizedRaw')
92 | ->with(true);
93 |
94 | $view->expects($this->once())
95 | ->method('isRecent')
96 | ->with(true);
97 |
98 | $view->expects($this->once())
99 | ->method('render')
100 | ->willReturn(json_encode(self::STATS_DATA));
101 |
102 | $adapter = new MemoryAdapter;
103 |
104 | $filesystem = new Filesystem($adapter);
105 |
106 | $input = new ArrayInput(
107 | [
108 | 'command' => 'snapshot:recently-updated',
109 | ]
110 | );
111 | $output = new BufferedOutput;
112 |
113 | $application = new Application($input, $output);
114 |
115 | $command = new SnapshotRecentlyUpdatedCommand($view, $filesystem);
116 | $command->setApplication($application);
117 |
118 | $this->assertSame(0, $command->execute($input, $output));
119 |
120 | $screenOutput = $output->fetch();
121 |
122 | $this->assertStringContainsString('Snapshot recorded.', $screenOutput);
123 | $this->assertCount(1, $filesystem->listContents());
124 | }
125 |
126 | /**
127 | * @testdox The command creates a full snapshot for a single source
128 | */
129 | public function testTheCommandCreatesAFullSnapshotForASingleSource(): void
130 | {
131 | $source = 'db_type';
132 |
133 | /** @var MockObject|StatsJsonView $view */
134 | $view = $this->createMock(StatsJsonView::class);
135 | $view->expects($this->once())
136 | ->method('isAuthorizedRaw')
137 | ->with(true);
138 |
139 | $view->expects($this->once())
140 | ->method('isRecent')
141 | ->with(true);
142 |
143 | $view->expects($this->once())
144 | ->method('setSource')
145 | ->with($source);
146 |
147 | $view->expects($this->once())
148 | ->method('render')
149 | ->willReturn(json_encode(self::STATS_DATA[$source]));
150 |
151 | $adapter = new MemoryAdapter;
152 |
153 | $filesystem = new Filesystem($adapter);
154 |
155 | $input = new ArrayInput(
156 | [
157 | 'command' => 'snapshot:recently-updated',
158 | '--source' => $source,
159 | ]
160 | );
161 | $output = new BufferedOutput;
162 |
163 | $application = new Application($input, $output);
164 |
165 | $command = new SnapshotRecentlyUpdatedCommand($view, $filesystem);
166 | $command->setApplication($application);
167 |
168 | $this->assertSame(0, $command->execute($input, $output));
169 |
170 | $screenOutput = $output->fetch();
171 |
172 | $this->assertStringContainsString('Snapshot recorded.', $screenOutput);
173 | $this->assertCount(1, $filesystem->listContents());
174 | }
175 |
176 | /**
177 | * @testdox The command does not create a snapshot for an invalid source
178 | */
179 | public function testTheCommandDoesNotCreateASnapshotForAnInvalidSource(): void
180 | {
181 | $this->expectException(InvalidOptionException::class);
182 |
183 | $source = 'bad';
184 |
185 | /** @var MockObject|StatsJsonView $view */
186 | $view = $this->createMock(StatsJsonView::class);
187 | $view->expects($this->once())
188 | ->method('isAuthorizedRaw')
189 | ->with(true);
190 |
191 | $view->expects($this->once())
192 | ->method('isRecent')
193 | ->with(true);
194 |
195 | $view->expects($this->never())
196 | ->method('setSource');
197 |
198 | $view->expects($this->never())
199 | ->method('render');
200 |
201 | $adapter = new MemoryAdapter;
202 |
203 | $filesystem = new Filesystem($adapter);
204 |
205 | $input = new ArrayInput(
206 | [
207 | 'command' => 'snapshot:recently-updated',
208 | '--source' => $source,
209 | ]
210 | );
211 | $output = new BufferedOutput;
212 |
213 | $application = new Application($input, $output);
214 |
215 | $command = new SnapshotRecentlyUpdatedCommand($view, $filesystem);
216 | $command->setApplication($application);
217 |
218 | $command->execute($input, $output);
219 | }
220 | }
221 |
--------------------------------------------------------------------------------
/tests/Commands/Tags/FetchJoomlaTagsCommandTest.php:
--------------------------------------------------------------------------------
1 | '3.9.0',
46 | ],
47 | (object) [
48 | 'name' => '3.9.1',
49 | ],
50 | (object) [
51 | 'name' => '3.9.2',
52 | ],
53 | (object) [
54 | 'name' => '3.9.3',
55 | ],
56 | (object) [
57 | 'name' => '3.9.4',
58 | ],
59 | ];
60 | }
61 | };
62 |
63 | $github = new class($githubRepositories) extends GitHub
64 | {
65 | private $mockedRepositories;
66 |
67 | public function __construct(Repositories $repositories)
68 | {
69 | parent::__construct();
70 |
71 | $this->mockedRepositories = $repositories;
72 | }
73 |
74 | public function __get($name)
75 | {
76 | if ($name === 'repositories')
77 | {
78 | return $this->mockedRepositories;
79 | }
80 |
81 | return parent::__get($name);
82 | }
83 | };
84 |
85 | $adapter = new MemoryAdapter;
86 |
87 | $filesystem = new Filesystem($adapter);
88 |
89 | $input = new ArrayInput(
90 | [
91 | 'command' => 'tags:joomla',
92 | ]
93 | );
94 | $output = new BufferedOutput;
95 |
96 | $application = new Application($input, $output);
97 |
98 | $command = new FetchJoomlaTagsCommand($github, $filesystem);
99 | $command->setApplication($application);
100 |
101 | $this->assertSame(0, $command->execute($input, $output));
102 |
103 | $screenOutput = $output->fetch();
104 |
105 | $this->assertStringContainsString('Joomla! versions updated.', $screenOutput);
106 |
107 | $versions = json_decode($filesystem->read('joomla.json'), true);
108 |
109 | $this->assertContains('3.9.5', $versions, 'The command should add the next patch release to the allowed version list');
110 | $this->assertContains('3.10.0', $versions, 'The command should add the next minor release to the allowed version list');
111 | }
112 |
113 | /**
114 | * @testdox The command fetches the release tags from the Joomla! repository with a paginated response
115 | */
116 | public function testTheCommandFetchesTheReleaseTagsFromTheJoomlaRepositoryWithAPaginatedResponse(): void
117 | {
118 | $response = new Response;
119 |
120 | $githubRepositories = new class extends Repositories
121 | {
122 | private $execution = 0;
123 |
124 | public function getApiResponse()
125 | {
126 | switch ($this->execution)
127 | {
128 | case 1:
129 | $response = new Response;
130 | $response = $response->withHeader('Link', '; rel="next", ; rel="last"');
131 |
132 | return $response;
133 |
134 | default:
135 | return new Response;
136 | }
137 | }
138 |
139 | public function getTags($owner, $repo, $page = 0)
140 | {
141 | $this->execution++;
142 |
143 | switch ($this->execution)
144 | {
145 | case 1:
146 | return [
147 | (object) [
148 | 'name' => '3.7.0',
149 | ],
150 | (object) [
151 | 'name' => '3.7.1',
152 | ],
153 | (object) [
154 | 'name' => '3.7.2',
155 | ],
156 | (object) [
157 | 'name' => '3.7.3',
158 | ],
159 | (object) [
160 | 'name' => '3.7.4',
161 | ],
162 | (object) [
163 | 'name' => '3.7.5',
164 | ],
165 | ];
166 |
167 | case 2:
168 | return [
169 | (object) [
170 | 'name' => '3.8.0',
171 | ],
172 | (object) [
173 | 'name' => '3.8.1',
174 | ],
175 | (object) [
176 | 'name' => '3.8.2',
177 | ],
178 | (object) [
179 | 'name' => '3.8.3',
180 | ],
181 | (object) [
182 | 'name' => '3.8.4',
183 | ],
184 | (object) [
185 | 'name' => '3.8.5',
186 | ],
187 | (object) [
188 | 'name' => '3.8.6',
189 | ],
190 | (object) [
191 | 'name' => '3.8.7',
192 | ],
193 | (object) [
194 | 'name' => '3.8.8',
195 | ],
196 | (object) [
197 | 'name' => '3.8.9',
198 | ],
199 | (object) [
200 | 'name' => '3.8.10',
201 | ],
202 | (object) [
203 | 'name' => '3.8.11',
204 | ],
205 | (object) [
206 | 'name' => '3.8.12',
207 | ],
208 | (object) [
209 | 'name' => '3.8.13',
210 | ],
211 | ];
212 |
213 | case 3:
214 | return [
215 | (object) [
216 | 'name' => '3.9.0',
217 | ],
218 | (object) [
219 | 'name' => '3.9.1',
220 | ],
221 | (object) [
222 | 'name' => '3.9.2',
223 | ],
224 | (object) [
225 | 'name' => '3.9.3',
226 | ],
227 | (object) [
228 | 'name' => '3.9.4',
229 | ],
230 | ];
231 | }
232 | }
233 | };
234 |
235 | $github = new class($githubRepositories) extends GitHub
236 | {
237 | private $mockedRepositories;
238 |
239 | public function __construct(Repositories $repositories)
240 | {
241 | parent::__construct();
242 |
243 | $this->mockedRepositories = $repositories;
244 | }
245 |
246 | public function __get($name)
247 | {
248 | if ($name === 'repositories')
249 | {
250 | return $this->mockedRepositories;
251 | }
252 |
253 | return parent::__get($name);
254 | }
255 | };
256 |
257 | $adapter = new MemoryAdapter;
258 |
259 | $filesystem = new Filesystem($adapter);
260 |
261 | $input = new ArrayInput(
262 | [
263 | 'command' => 'tags:joomla',
264 | ]
265 | );
266 | $output = new BufferedOutput;
267 |
268 | $application = new Application($input, $output);
269 |
270 | $command = new FetchJoomlaTagsCommand($github, $filesystem);
271 | $command->setApplication($application);
272 |
273 | $this->assertSame(0, $command->execute($input, $output));
274 |
275 | $screenOutput = $output->fetch();
276 |
277 | $this->assertStringContainsString('Fetching page 2 of 3 pages of tags.', $screenOutput);
278 | $this->assertStringContainsString('Joomla! versions updated.', $screenOutput);
279 |
280 | $versions = json_decode($filesystem->read('joomla.json'), true);
281 |
282 | $this->assertContains('3.9.5', $versions, 'The command should add the next patch release to the allowed version list');
283 | $this->assertContains('3.10.0', $versions, 'The command should add the next minor release to the allowed version list');
284 | }
285 | }
286 |
--------------------------------------------------------------------------------
/tests/Commands/Tags/FetchPhpTagsCommandTest.php:
--------------------------------------------------------------------------------
1 | 'php-7.2.0',
46 | ],
47 | (object) [
48 | 'name' => 'php-7.2.1',
49 | ],
50 | (object) [
51 | 'name' => 'php-7.2.2',
52 | ],
53 | (object) [
54 | 'name' => 'php-7.2.3',
55 | ],
56 | (object) [
57 | 'name' => 'php-7.2.4',
58 | ],
59 | (object) [
60 | 'name' => 'php-7.3.0',
61 | ],
62 | (object) [
63 | 'name' => 'php-7.3.1',
64 | ],
65 | (object) [
66 | 'name' => 'php-7.3.2',
67 | ],
68 | (object) [
69 | 'name' => 'php-7.3.3',
70 | ],
71 | (object) [
72 | 'name' => 'php-7.3.4',
73 | ],
74 | (object) [
75 | 'name' => 'php-7.4.0',
76 | ],
77 | (object) [
78 | 'name' => 'php-7.4.1',
79 | ],
80 | (object) [
81 | 'name' => 'php-7.4.2',
82 | ],
83 | (object) [
84 | 'name' => 'php-7.4.3',
85 | ],
86 | (object) [
87 | 'name' => 'php-7.4.4',
88 | ],
89 | ];
90 | }
91 | };
92 |
93 | $github = new class($githubRepositories) extends GitHub
94 | {
95 | private $mockedRepositories;
96 |
97 | public function __construct(Repositories $repositories)
98 | {
99 | parent::__construct();
100 |
101 | $this->mockedRepositories = $repositories;
102 | }
103 |
104 | public function __get($name)
105 | {
106 | if ($name === 'repositories')
107 | {
108 | return $this->mockedRepositories;
109 | }
110 |
111 | return parent::__get($name);
112 | }
113 | };
114 |
115 | $adapter = new MemoryAdapter;
116 |
117 | $filesystem = new Filesystem($adapter);
118 |
119 | $input = new ArrayInput(
120 | [
121 | 'command' => 'tags:php',
122 | ]
123 | );
124 | $output = new BufferedOutput;
125 |
126 | $application = new Application($input, $output);
127 |
128 | $command = new FetchPhpTagsCommand($github, $filesystem);
129 | $command->setApplication($application);
130 |
131 | $this->assertSame(0, $command->execute($input, $output));
132 |
133 | $screenOutput = $output->fetch();
134 |
135 | $this->assertStringContainsString('PHP versions updated.', $screenOutput);
136 |
137 | $versions = json_decode($filesystem->read('php.json'), true);
138 |
139 | $this->assertContains('7.2.5', $versions, 'The command should add the next patch release for the 7.2 branch to the allowed version list');
140 | $this->assertContains('7.3.5', $versions, 'The command should add the next patch release for the 7.3 branch to the allowed version list');
141 | $this->assertContains('7.4.5', $versions, 'The command should add the next patch release for the 7.4 branch to the allowed version list');
142 | $this->assertContains('8.0.0', $versions, 'The command should add the next major release to the allowed version list');
143 | }
144 |
145 | /**
146 | * @testdox The command fetches the release tags from the PHP repository with a paginated response
147 | */
148 | public function testTheCommandFetchesTheReleaseTagsFromThePhpRepositoryWithAPaginatedResponse(): void
149 | {
150 | $response = new Response;
151 |
152 | $githubRepositories = new class extends Repositories
153 | {
154 | private $execution = 0;
155 |
156 | public function getApiResponse()
157 | {
158 | switch ($this->execution)
159 | {
160 | case 1:
161 | $response = new Response;
162 | $response = $response->withHeader('Link', '; rel="next", ; rel="last"');
163 |
164 | return $response;
165 |
166 | default:
167 | return new Response;
168 | }
169 | }
170 |
171 | public function getTags($owner, $repo, $page = 0)
172 | {
173 | $this->execution++;
174 |
175 | switch ($this->execution)
176 | {
177 | case 1:
178 | return [
179 | (object) [
180 | 'name' => 'php-7.2.0',
181 | ],
182 | (object) [
183 | 'name' => 'php-7.2.1',
184 | ],
185 | (object) [
186 | 'name' => 'php-7.2.2',
187 | ],
188 | (object) [
189 | 'name' => 'php-7.2.3',
190 | ],
191 | (object) [
192 | 'name' => 'php-7.2.4',
193 | ],
194 | ];
195 |
196 | case 2:
197 | return [
198 | (object) [
199 | 'name' => 'php-7.3.0',
200 | ],
201 | (object) [
202 | 'name' => 'php-7.3.1',
203 | ],
204 | (object) [
205 | 'name' => 'php-7.3.2',
206 | ],
207 | (object) [
208 | 'name' => 'php-7.3.3',
209 | ],
210 | (object) [
211 | 'name' => 'php-7.3.4',
212 | ],
213 | ];
214 |
215 | case 3:
216 | return [
217 | (object) [
218 | 'name' => 'php-7.4.0',
219 | ],
220 | (object) [
221 | 'name' => 'php-7.4.1',
222 | ],
223 | (object) [
224 | 'name' => 'php-7.4.2',
225 | ],
226 | (object) [
227 | 'name' => 'php-7.4.3',
228 | ],
229 | (object) [
230 | 'name' => 'php-7.4.4',
231 | ],
232 | ];
233 | }
234 | }
235 | };
236 |
237 | $github = new class($githubRepositories) extends GitHub
238 | {
239 | private $mockedRepositories;
240 |
241 | public function __construct(Repositories $repositories)
242 | {
243 | parent::__construct();
244 |
245 | $this->mockedRepositories = $repositories;
246 | }
247 |
248 | public function __get($name)
249 | {
250 | if ($name === 'repositories')
251 | {
252 | return $this->mockedRepositories;
253 | }
254 |
255 | return parent::__get($name);
256 | }
257 | };
258 |
259 | $adapter = new MemoryAdapter;
260 |
261 | $filesystem = new Filesystem($adapter);
262 |
263 | $input = new ArrayInput(
264 | [
265 | 'command' => 'tags:php',
266 | ]
267 | );
268 | $output = new BufferedOutput;
269 |
270 | $application = new Application($input, $output);
271 |
272 | $command = new FetchPhpTagsCommand($github, $filesystem);
273 | $command->setApplication($application);
274 |
275 | $this->assertSame(0, $command->execute($input, $output));
276 |
277 | $screenOutput = $output->fetch();
278 |
279 | $this->assertStringContainsString('Fetching page 2 of 3 pages of tags.', $screenOutput);
280 | $this->assertStringContainsString('PHP versions updated.', $screenOutput);
281 |
282 | $versions = json_decode($filesystem->read('php.json'), true);
283 |
284 | $this->assertContains('7.2.5', $versions, 'The command should add the next patch release for the 7.2 branch to the allowed version list');
285 | $this->assertContains('7.3.5', $versions, 'The command should add the next patch release for the 7.3 branch to the allowed version list');
286 | $this->assertContains('7.4.5', $versions, 'The command should add the next patch release for the 7.4 branch to the allowed version list');
287 | $this->assertContains('8.0.0', $versions, 'The command should add the next major release to the allowed version list');
288 | }
289 | }
290 |
--------------------------------------------------------------------------------
/tests/Controllers/DisplayStatisticsControllerTest.php:
--------------------------------------------------------------------------------
1 | runMigrations();
43 | }
44 |
45 | /**
46 | * This method is called before each test.
47 | *
48 | * @return void
49 | */
50 | protected function setUp(): void
51 | {
52 | parent::setUp();
53 |
54 | static::$dbManager->loadExampleData();
55 |
56 | $this->kernel = new class(static::$connection) extends WebKernel
57 | {
58 | private $database;
59 |
60 | public function __construct(DatabaseInterface $database)
61 | {
62 | $this->database = $database;
63 | }
64 |
65 | protected function buildContainer(): Container
66 | {
67 | $container = parent::buildContainer();
68 |
69 | // Overload the database service with the test database
70 | $container->share(DatabaseDriver::class, $this->database);
71 |
72 | return $container;
73 | }
74 |
75 | public function getContainer()
76 | {
77 | return parent::getContainer();
78 | }
79 | };
80 | }
81 |
82 | /**
83 | * Tears down the fixture, for example, close a network connection.
84 | * This method is called after a test is executed.
85 | *
86 | * @return void
87 | */
88 | protected function tearDown(): void
89 | {
90 | static::$dbManager->clearTables();
91 |
92 | parent::tearDown();
93 | }
94 |
95 | /**
96 | * @testdox Sanitized statistics are returned
97 | */
98 | public function testSanitizedStatisticsAreReturned(): void
99 | {
100 | $this->kernel->boot();
101 |
102 | /** @var DisplayStatisticsController $controller */
103 | $controller = $this->kernel->getContainer()->get(DisplayStatisticsController::class);
104 |
105 | $this->assertTrue($controller->execute());
106 |
107 | /** @var WebApplication $application */
108 | $application = $this->kernel->getContainer()->get(AbstractApplication::class);
109 |
110 | /** @var JsonResponse $response */
111 | $response = $application->getResponse();
112 |
113 | $this->assertSame(200, $response->getStatusCode());
114 |
115 | $responseBody = json_decode($application->getBody(), true);
116 |
117 | $this->assertArrayHasKey('5.5', $responseBody['data']['php_version'], 'The response should contain the PHP version data grouped by branch.');
118 | $this->assertArrayNotHasKey('5.5.38', $responseBody['data']['php_version'], 'The response should contain the PHP version data grouped by branch.');
119 | }
120 |
121 | /**
122 | * @testdox Unsanitized statistics are returned
123 | */
124 | public function testUnsanitizedStatisticsAreReturned(): void
125 | {
126 | $this->kernel->boot();
127 |
128 | /** @var WebApplication $application */
129 | $application = $this->kernel->getContainer()->get(AbstractApplication::class);
130 |
131 | // Fake the request data and params
132 | $application->set('stats.rawdata', 'testing');
133 | $application->input->server->set('HTTP_JOOMLA_RAW', 'testing');
134 |
135 | /** @var DisplayStatisticsController $controller */
136 | $controller = $this->kernel->getContainer()->get(DisplayStatisticsController::class);
137 |
138 | $this->assertTrue($controller->execute());
139 |
140 | /** @var JsonResponse $response */
141 | $response = $application->getResponse();
142 |
143 | $this->assertSame(200, $response->getStatusCode());
144 |
145 | $responseBody = json_decode($application->getBody(), true);
146 |
147 | foreach ($responseBody['data']['php_version'] as $info)
148 | {
149 | if ($info['name'] === '5.5')
150 | {
151 | $this->fail('The response should contain the raw PHP version data.');
152 | }
153 | }
154 | }
155 |
156 | /**
157 | * @testdox Statistics for a single source are returned
158 | */
159 | public function testStatisticsForASingleSourceAreReturned(): void
160 | {
161 | $this->kernel->boot();
162 |
163 | /** @var WebApplication $application */
164 | $application = $this->kernel->getContainer()->get(AbstractApplication::class);
165 |
166 | // Fake the request data
167 | $application->input->set('source', 'php_version');
168 |
169 | /** @var DisplayStatisticsController $controller */
170 | $controller = $this->kernel->getContainer()->get(DisplayStatisticsController::class);
171 |
172 | $this->assertTrue($controller->execute());
173 |
174 | /** @var JsonResponse $response */
175 | $response = $application->getResponse();
176 |
177 | $this->assertSame(200, $response->getStatusCode());
178 |
179 | $responseBody = json_decode($application->getBody(), true);
180 |
181 | $this->assertArrayHasKey('php_version', $responseBody['data'], 'The response should only contain the PHP version data.');
182 | $this->assertArrayNotHasKey('cms_version', $responseBody['data'], 'The response should only contain the PHP version data.');
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/tests/Database/MigrationsTest.php:
--------------------------------------------------------------------------------
1 | migrations = new Migrations(
39 | static::$connection,
40 | new Filesystem(new Local(APPROOT . '/etc/migrations'))
41 | );
42 | }
43 |
44 | /**
45 | * @testdox The migration status is checked without the table created
46 | */
47 | public function testTheMigrationStatusIsCheckedWithoutTheTableCreated(): void
48 | {
49 | $status = $this->migrations->checkStatus();
50 |
51 | $this->assertFalse($status->tableExists);
52 | }
53 |
54 | /**
55 | * @testdox The migration status is checked after executing the first migration
56 | */
57 | public function testTheMigrationStatusIsCheckedAfterExecutingTheFirstMigration(): void
58 | {
59 | $this->migrations->migrateDatabase('20160618001');
60 |
61 | $status = $this->migrations->checkStatus();
62 |
63 | $this->assertTrue($status->tableExists);
64 | $this->assertSame('20160618001', $status->currentVersion);
65 | $this->assertGreaterThanOrEqual(1, $status->missingMigrations);
66 | }
67 |
68 | /**
69 | * @testdox The migration status is checked after executing all migrations
70 | */
71 | public function testTheMigrationStatusIsCheckedAfterExecutingAllMigrations(): void
72 | {
73 | $this->migrations->migrateDatabase();
74 |
75 | $status = $this->migrations->checkStatus();
76 |
77 | $this->assertTrue($status->tableExists);
78 | $this->assertTrue($status->latest);
79 | $this->assertSame(0, $status->missingMigrations);
80 | }
81 |
82 | /**
83 | * @testdox Migrations fail with an unknown migration
84 | */
85 | public function testMigrationsFailWithAnUnknownMigration(): void
86 | {
87 | $this->migrations->migrateDatabase();
88 |
89 | $this->expectException(UnknownMigrationException::class);
90 |
91 | $this->migrations->migrateDatabase('will-never-exist');
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/tests/DatabaseManager.php:
--------------------------------------------------------------------------------
1 | getConnection();
30 |
31 | $modifiedTimestamp = (new \DateTime('now', new \DateTimeZone('UTC')))->format($db->getDateFormat());
32 |
33 | $data = [
34 | [
35 | 'php_version' => '5.5.38',
36 | 'db_type' => 'mysqli',
37 | 'db_version' => '5.6.41',
38 | 'cms_version' => '3.9.4',
39 | 'server_os' => 'Darwin 14.1.0',
40 | 'unique_id' => 'a1b2c3d4',
41 | 'modified' => $modifiedTimestamp,
42 | ],
43 | [
44 | 'php_version' => '5.6.39',
45 | 'db_type' => 'mysql',
46 | 'db_version' => '5.7.26',
47 | 'cms_version' => '3.9.2',
48 | 'server_os' => 'Windows NT 10.0',
49 | 'unique_id' => 'a2b3c4d5',
50 | 'modified' => $modifiedTimestamp,
51 | ],
52 | [
53 | 'php_version' => '7.0.33',
54 | 'db_type' => 'pdomysql',
55 | 'db_version' => '8.0.14',
56 | 'cms_version' => '3.8.13',
57 | 'server_os' => 'Linux 4.14.68',
58 | 'unique_id' => 'a3b4c5d6',
59 | 'modified' => $modifiedTimestamp,
60 | ],
61 | [
62 | 'php_version' => '7.1.27',
63 | 'db_type' => 'pgsql',
64 | 'db_version' => '9.6.12',
65 | 'cms_version' => '3.7.5',
66 | 'server_os' => 'FreeBSD 12.0-STABLE',
67 | 'unique_id' => 'a4b5c6d7',
68 | 'modified' => $modifiedTimestamp,
69 | ],
70 | [
71 | 'php_version' => '7.2.16',
72 | 'db_type' => 'postgresql',
73 | 'db_version' => '9.2.24',
74 | 'cms_version' => '3.6.5',
75 | 'server_os' => 'OpenBSD 6.4',
76 | 'unique_id' => 'a5b6c7d8',
77 | 'modified' => $modifiedTimestamp,
78 | ],
79 | ];
80 |
81 | // Seed the main table first
82 | foreach ($data as $row)
83 | {
84 | $rowAsObject = (object) $row;
85 |
86 | $db->insertObject('#__jstats', $rowAsObject, ['unique_id']);
87 | }
88 |
89 | // Run the queries to seed the counter tables
90 | foreach (DatabaseDriver::splitSql(file_get_contents(APPROOT . '/etc/unatantum.sql')) as $query)
91 | {
92 | $query = trim($query);
93 |
94 | if (!empty($query))
95 | {
96 | $db->setQuery($query)->execute();
97 | }
98 | }
99 | }
100 |
101 | /**
102 | * Run the migrations to build the application database
103 | *
104 | * @return void
105 | */
106 | public function runMigrations(): void
107 | {
108 | $db = $this->getConnection();
109 |
110 | (new Migrations($db, new Filesystem(new Local(APPROOT . '/etc/migrations'))))->migrateDatabase();
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/tests/DatabaseTestCase.php:
--------------------------------------------------------------------------------
1 | getMockServer();
40 | }
41 |
42 | return parent::__get($name);
43 | }
44 |
45 | private function getMockServer(): Input
46 | {
47 | if ($this->mockServer === null)
48 | {
49 | $this->mockServer = new static(
50 | [
51 | 'HTTP_HOST' => 'developer.joomla.org',
52 | 'HTTP_USER_AGENT' => 'JoomlaStatsTest/1.0',
53 | 'REMOTE_ADDR' => '127.0.0.1',
54 | 'REQUEST_METHOD' => 'GET',
55 | ]
56 | );
57 | }
58 |
59 | return $this->mockServer;
60 | }
61 | };
62 |
63 | $application = $this->createMock(AbstractWebApplication::class);
64 |
65 | $application->expects($this->atLeastOnce())
66 | ->method('getInput')
67 | ->willReturn($mockInput);
68 |
69 | $application->expects($this->once())
70 | ->method('get')
71 | ->with('uri.base.path')
72 | ->willReturn('/');
73 |
74 | $analytics = new class extends Analytics
75 | {
76 | private $didSend = false;
77 |
78 | public function isSent(): bool
79 | {
80 | return $this->didSend === true;
81 | }
82 |
83 | public function sendPageview()
84 | {
85 | $this->didSend = true;
86 |
87 | return new NullAnalyticsResponse;
88 | }
89 | };
90 |
91 | $event = new ApplicationEvent(ApplicationEvents::BEFORE_EXECUTE, $application);
92 |
93 | $logger = new TestLogger;
94 |
95 | $subscriber = new AnalyticsSubscriber($analytics);
96 | $subscriber->setLogger($logger);
97 | $subscriber->onBeforeExecute($event);
98 |
99 | $this->assertTrue($analytics->isSent());
100 | }
101 |
102 | /**
103 | * @testdox Analytics are not recorded for POST requests to the live site
104 | */
105 | public function testAnalyticsAreNotRecordedForPostRequestsToTheLiveSite(): void
106 | {
107 | $mockInput = new class extends Input
108 | {
109 | private $mockServer;
110 |
111 | public function __get($name)
112 | {
113 | if ($name === 'server')
114 | {
115 | return $this->getMockServer();
116 | }
117 |
118 | return parent::__get($name);
119 | }
120 |
121 | private function getMockServer(): Input
122 | {
123 | if ($this->mockServer === null)
124 | {
125 | $this->mockServer = new static(
126 | [
127 | 'HTTP_HOST' => 'developer.joomla.org',
128 | 'HTTP_USER_AGENT' => 'JoomlaStatsTest/1.0',
129 | 'REMOTE_ADDR' => '127.0.0.1',
130 | 'REQUEST_METHOD' => 'POST',
131 | ]
132 | );
133 | }
134 |
135 | return $this->mockServer;
136 | }
137 | };
138 |
139 | $application = $this->createMock(AbstractWebApplication::class);
140 |
141 | $application->expects($this->atLeastOnce())
142 | ->method('getInput')
143 | ->willReturn($mockInput);
144 |
145 | $application->expects($this->never())
146 | ->method('get');
147 |
148 | $analytics = new class extends Analytics
149 | {
150 | private $didSend = false;
151 |
152 | public function isSent(): bool
153 | {
154 | return $this->didSend === true;
155 | }
156 |
157 | public function sendPageview()
158 | {
159 | $this->didSend = true;
160 |
161 | return new NullAnalyticsResponse;
162 | }
163 | };
164 |
165 | $event = new ApplicationEvent(ApplicationEvents::BEFORE_EXECUTE, $application);
166 |
167 | $logger = new TestLogger;
168 |
169 | $subscriber = new AnalyticsSubscriber($analytics);
170 | $subscriber->setLogger($logger);
171 | $subscriber->onBeforeExecute($event);
172 |
173 | $this->assertFalse($analytics->isSent());
174 | }
175 |
176 | /**
177 | * @testdox An error while sending analytics is handled
178 | */
179 | public function testAnErrorWhileSendingAnalyticsIsHandled(): void
180 | {
181 | $mockInput = new class extends Input
182 | {
183 | private $mockServer;
184 |
185 | public function __get($name)
186 | {
187 | if ($name === 'server')
188 | {
189 | return $this->getMockServer();
190 | }
191 |
192 | return parent::__get($name);
193 | }
194 |
195 | private function getMockServer(): Input
196 | {
197 | if ($this->mockServer === null)
198 | {
199 | $this->mockServer = new static(
200 | [
201 | 'HTTP_HOST' => 'developer.joomla.org',
202 | 'HTTP_USER_AGENT' => 'JoomlaStatsTest/1.0',
203 | 'REMOTE_ADDR' => '127.0.0.1',
204 | 'REQUEST_METHOD' => 'GET',
205 | ]
206 | );
207 | }
208 |
209 | return $this->mockServer;
210 | }
211 | };
212 |
213 | $application = $this->createMock(AbstractWebApplication::class);
214 |
215 | $application->expects($this->atLeastOnce())
216 | ->method('getInput')
217 | ->willReturn($mockInput);
218 |
219 | $application->expects($this->once())
220 | ->method('get')
221 | ->with('uri.base.path')
222 | ->willReturn('/');
223 |
224 | $analytics = new class extends Analytics
225 | {
226 | private $didSend = false;
227 |
228 | public function isSent(): bool
229 | {
230 | return $this->didSend === true;
231 | }
232 |
233 | public function sendPageview(): void
234 | {
235 | throw new \Exception('Testing error handling');
236 | }
237 | };
238 |
239 | $event = new ApplicationEvent(ApplicationEvents::BEFORE_EXECUTE, $application);
240 |
241 | $logger = new TestLogger;
242 |
243 | $subscriber = new AnalyticsSubscriber($analytics);
244 | $subscriber->setLogger($logger);
245 | $subscriber->onBeforeExecute($event);
246 |
247 | $this->assertTrue($logger->hasErrorThatContains('Error sending analytics data.'));
248 | }
249 | }
250 |
--------------------------------------------------------------------------------
/tests/EventListener/ErrorSubscriberTest.php:
--------------------------------------------------------------------------------
1 | createMock(ConsoleApplication::class);
39 |
40 | $application->expects($this->once())
41 | ->method('getConsoleInput')
42 | ->willReturn($this->createMock(InputInterface::class));
43 |
44 | $application->expects($this->once())
45 | ->method('getConsoleOutput')
46 | ->willReturn($output);
47 |
48 | $event = new ConsoleApplicationErrorEvent($error, $application);
49 |
50 | $logger = new TestLogger;
51 |
52 | $subscriber = new ErrorSubscriber;
53 | $subscriber->setLogger($logger);
54 | $subscriber->handleConsoleError($event);
55 |
56 | $this->assertTrue(
57 | $logger->hasErrorThatContains('Uncaught Throwable of type Exception caught.'),
58 | 'An error from the console application should be logged.'
59 | );
60 |
61 | $screenOutput = $output->fetch();
62 |
63 | $this->assertStringContainsString(
64 | 'Uncaught Throwable of type Exception caught: Testing',
65 | $screenOutput,
66 | 'An error from the console application should be output.'
67 | );
68 | }
69 |
70 | /**
71 | * @testdox Method Not Allowed Errors are handled for the web application
72 | */
73 | public function testMethodNotAllowedErrorsAreHandledForTheWebApplication(): void
74 | {
75 | $error = new MethodNotAllowedException(['POST']);
76 |
77 | $mockInput = new class extends Input
78 | {
79 | private $mockServer;
80 |
81 | public function __get($name)
82 | {
83 | if ($name === 'server')
84 | {
85 | return $this->getMockServer();
86 | }
87 |
88 | return parent::__get($name);
89 | }
90 |
91 | private function getMockServer(): Input
92 | {
93 | if ($this->mockServer === null)
94 | {
95 | $this->mockServer = new static(
96 | [
97 | 'HTTP_HOST' => 'developer.joomla.org',
98 | 'HTTP_USER_AGENT' => 'JoomlaStatsTest/1.0',
99 | 'REMOTE_ADDR' => '127.0.0.1',
100 | 'REQUEST_METHOD' => 'GET',
101 | ]
102 | );
103 | }
104 |
105 | return $this->mockServer;
106 | }
107 | };
108 |
109 | $application = $this->createMock(AbstractWebApplication::class);
110 |
111 | $application->expects($this->atLeastOnce())
112 | ->method('getInput')
113 | ->willReturn($mockInput);
114 |
115 | $application->expects($this->once())
116 | ->method('get')
117 | ->with('uri.route')
118 | ->willReturn('/hello');
119 |
120 | $application->expects($this->once())
121 | ->method('allowCache')
122 | ->with(false)
123 | ->willReturn(false);
124 |
125 | $application->expects($this->once())
126 | ->method('setResponse');
127 |
128 | $application->expects($this->once())
129 | ->method('setHeader');
130 |
131 | $event = new ApplicationErrorEvent($error, $application);
132 |
133 | $logger = new TestLogger;
134 |
135 | $subscriber = new ErrorSubscriber;
136 | $subscriber->setLogger($logger);
137 | $subscriber->handleWebError($event);
138 |
139 | $this->assertTrue(
140 | $logger->hasErrorThatContains('Route `/hello` not supported by method `GET`'),
141 | 'An error from the web application should be logged.'
142 | );
143 | }
144 |
145 | /**
146 | * @testdox Route Not Found Errors are handled for the web application
147 | */
148 | public function testRouteNotFoundErrorsAreHandledForTheWebApplication(): void
149 | {
150 | $error = new RouteNotFoundException('Testing', 404);
151 |
152 | $application = $this->createMock(AbstractWebApplication::class);
153 |
154 | $application->expects($this->once())
155 | ->method('get')
156 | ->with('uri.route')
157 | ->willReturn('/hello');
158 |
159 | $application->expects($this->once())
160 | ->method('allowCache')
161 | ->with(false)
162 | ->willReturn(false);
163 |
164 | $application->expects($this->once())
165 | ->method('setResponse');
166 |
167 | $event = new ApplicationErrorEvent($error, $application);
168 |
169 | $logger = new TestLogger;
170 |
171 | $subscriber = new ErrorSubscriber;
172 | $subscriber->setLogger($logger);
173 | $subscriber->handleWebError($event);
174 |
175 | $this->assertTrue(
176 | $logger->hasErrorThatContains('Route `/hello` not found'),
177 | 'An error from the web application should be logged.'
178 | );
179 | }
180 |
181 | /**
182 | * @testdox Uncaught Exceptions are handled for the web application
183 | */
184 | public function testUncaughtExceptionsAreHandledForTheWebApplication(): void
185 | {
186 | $error = new \Exception('Testing');
187 |
188 | $application = $this->createMock(AbstractWebApplication::class);
189 |
190 | $application->expects($this->once())
191 | ->method('allowCache')
192 | ->with(false)
193 | ->willReturn(false);
194 |
195 | $application->expects($this->once())
196 | ->method('setResponse');
197 |
198 | $event = new ApplicationErrorEvent($error, $application);
199 |
200 | $logger = new TestLogger;
201 |
202 | $subscriber = new ErrorSubscriber;
203 | $subscriber->setLogger($logger);
204 | $subscriber->handleWebError($event);
205 |
206 | $this->assertTrue(
207 | $logger->hasErrorThatContains('Uncaught Throwable of type Exception caught.'),
208 | 'An error from the web application should be logged.'
209 | );
210 | }
211 | }
212 |
--------------------------------------------------------------------------------
/tests/Kernel/ConsoleKernelTest.php:
--------------------------------------------------------------------------------
1 | boot();
36 |
37 | $this->assertTrue($kernel->isBooted());
38 |
39 | $container = $kernel->getContainer();
40 |
41 | $this->assertSame(
42 | $container->get(LoggerInterface::class),
43 | $container->get('monolog.logger.cli'),
44 | 'The logger should be aliased to the correct service.'
45 | );
46 |
47 | $this->assertInstanceOf(
48 | Application::class,
49 | $container->get(AbstractApplication::class),
50 | 'The AbstractApplication should be aliased to the correct subclass.'
51 | );
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/tests/Kernel/WebKernelTest.php:
--------------------------------------------------------------------------------
1 | boot();
36 |
37 | $this->assertTrue($kernel->isBooted());
38 |
39 | $container = $kernel->getContainer();
40 |
41 | $this->assertSame(
42 | $container->get(LoggerInterface::class),
43 | $container->get('monolog.logger.application'),
44 | 'The logger should be aliased to the correct service.'
45 | );
46 |
47 | $this->assertInstanceOf(
48 | WebApplication::class,
49 | $container->get(AbstractApplication::class),
50 | 'The AbstractApplication should be aliased to the correct subclass.'
51 | );
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/tests/KernelTest.php:
--------------------------------------------------------------------------------
1 | executed = true;
35 | }
36 |
37 | public function isExecuted(): bool
38 | {
39 | return $this->executed === true;
40 | }
41 | };
42 |
43 | $kernel = new class($application) extends Kernel
44 | {
45 | private $application;
46 |
47 | public function __construct(AbstractApplication $application)
48 | {
49 | $this->application = $application;
50 | }
51 |
52 | protected function buildContainer(): Container
53 | {
54 | $container = parent::buildContainer();
55 |
56 | $container->share(AbstractApplication::class, $this->application, true);
57 |
58 | $container->alias('monolog', 'monolog.logger.application')
59 | ->alias('logger', 'monolog.logger.application')
60 | ->alias(Logger::class, 'monolog.logger.application')
61 | ->alias(LoggerInterface::class, 'monolog.logger.application');
62 |
63 | return $container;
64 | }
65 | };
66 |
67 | $kernel->run();
68 |
69 | $this->assertTrue($application->isExecuted());
70 | }
71 |
72 | /**
73 | * @testdox The Kernel is not run when an application is not registered
74 | */
75 | public function testTheKernelIsNotRunWhenAnApplicationIsNotRegistered(): void
76 | {
77 | $kernel = new class extends Kernel
78 | {
79 | protected function buildContainer(): Container
80 | {
81 | $container = parent::buildContainer();
82 |
83 | $container->alias('monolog', 'monolog.logger.application')
84 | ->alias('logger', 'monolog.logger.application')
85 | ->alias(Logger::class, 'monolog.logger.application')
86 | ->alias(LoggerInterface::class, 'monolog.logger.application');
87 |
88 | return $container;
89 | }
90 | };
91 |
92 | $this->expectException(\RuntimeException::class);
93 | $this->expectExceptionMessage('The application has not been registered with the container.');
94 |
95 | $kernel->run();
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/tests/Repositories/StatisticsRepositoryTest.php:
--------------------------------------------------------------------------------
1 | runMigrations();
37 | }
38 |
39 | /**
40 | * This method is called before each test.
41 | *
42 | * @return void
43 | */
44 | protected function setUp(): void
45 | {
46 | parent::setUp();
47 |
48 | static::$dbManager->loadExampleData();
49 |
50 | $this->repository = new StatisticsRepository(static::$connection);
51 | }
52 |
53 | /**
54 | * Tears down the fixture, for example, close a network connection.
55 | * This method is called after a test is executed.
56 | *
57 | * @return void
58 | */
59 | protected function tearDown(): void
60 | {
61 | static::$dbManager->clearTables();
62 |
63 | parent::tearDown();
64 | }
65 |
66 | /**
67 | * @testdox The data for all tables is returned
68 | */
69 | public function testTheDataForAllTablesIsReturned(): void
70 | {
71 | $data = $this->repository->getItems();
72 |
73 | foreach (StatisticsRepository::ALLOWED_SOURCES as $source)
74 | {
75 | $this->assertArrayHasKey($source, $data, sprintf('Missing data for "%s" source.', $source));
76 | }
77 | }
78 |
79 | /**
80 | * @testdox The data for a single table is returned
81 | */
82 | public function testTheDataForASingleTableIsReturned(): void
83 | {
84 | $data = $this->repository->getItems('cms_version');
85 |
86 | $this->assertNotEmpty($data);
87 | }
88 |
89 | /**
90 | * @testdox The data is not fetched for an unknown source
91 | */
92 | public function testTheDataIsNotFetchedForAnUnknownSource(): void
93 | {
94 | $this->expectException(\InvalidArgumentException::class);
95 | $this->expectExceptionMessage('An invalid data source was requested.');
96 | $this->expectExceptionCode(404);
97 |
98 | $data = $this->repository->getItems('does_not_exist');
99 | }
100 |
101 | /**
102 | * @testdox The recently updated data for all tables is returned
103 | */
104 | public function testTheRecentlyUpdatedDataForAllTablesIsReturned(): void
105 | {
106 | $data = $this->repository->getRecentlyUpdatedItems();
107 |
108 | foreach (StatisticsRepository::ALLOWED_SOURCES as $source)
109 | {
110 | $this->assertArrayHasKey($source, $data, sprintf('Missing data for "%s" source.', $source));
111 | }
112 | }
113 |
114 | /**
115 | * @testdox A new row is saved to the database
116 | */
117 | public function testANewRowIsSavedToTheDatabase(): void
118 | {
119 | $db = static::$connection;
120 |
121 | $id = 'unique-999';
122 |
123 | $row = (object) [
124 | 'php_version' => PHP_VERSION,
125 | 'db_type' => static::$connection->getName(),
126 | 'db_version' => static::$connection->getVersion(),
127 | 'cms_version' => '3.9.0',
128 | 'server_os' => php_uname('s') . ' ' . php_uname('r'),
129 | 'unique_id' => $id,
130 | ];
131 |
132 | $this->repository->save($row);
133 |
134 | $rowFromDatabase = $db->setQuery(
135 | $db->getQuery(true)
136 | ->select('*')
137 | ->from('#__jstats')
138 | ->where('unique_id = :unique_id')
139 | ->bind(':unique_id', $id, ParameterType::STRING)
140 | )->loadObject();
141 |
142 | $this->assertNotNull($rowFromDatabase, 'The newly inserted row could not be queried.');
143 |
144 | $this->assertEquals($rowFromDatabase, $row, 'The database did not return the data that was inserted.');
145 | $this->assertObjectHasAttribute('modified', $rowFromDatabase, 'The modified timestamp should be included in the query result.');
146 | }
147 |
148 | /**
149 | * @testdox An existing row is updated in the database
150 | */
151 | public function testAnExistingRowIsUpdatedInTheDatabase(): void
152 | {
153 | $db = static::$connection;
154 |
155 | $id = 'a1b2c3d4';
156 |
157 | $row = (object) [
158 | 'php_version' => PHP_VERSION,
159 | 'db_type' => static::$connection->getName(),
160 | 'db_version' => static::$connection->getVersion(),
161 | 'cms_version' => '3.9.0',
162 | 'server_os' => php_uname('s') . ' ' . php_uname('r'),
163 | 'unique_id' => $id,
164 | ];
165 |
166 | $this->repository->save($row);
167 |
168 | $rowFromDatabase = $db->setQuery(
169 | $db->getQuery(true)
170 | ->select('*')
171 | ->from('#__jstats')
172 | ->where('unique_id = :unique_id')
173 | ->bind(':unique_id', $id, ParameterType::STRING)
174 | )->loadObject();
175 |
176 | $this->assertNotNull($rowFromDatabase, 'The updated row could not be queried.');
177 |
178 | $this->assertEquals($rowFromDatabase, $row, 'The database did not return the data that was updated.');
179 | $this->assertObjectHasAttribute('modified', $rowFromDatabase, 'The modified timestamp should be included in the query result.');
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 |
7 | # Allow CORS
8 | Header always set Access-Control-Allow-Origin "*"
9 | Header always set Access-Control-Allow-Methods "GET"
10 | Header always set Access-Control-Allow-Headers "*"
11 |
12 | ##################### Security Header #####################
13 |
--------------------------------------------------------------------------------
/www/index.php:
--------------------------------------------------------------------------------
1 | 500,
21 | 'message' => 'Composer is not set up properly, please run "composer install".',
22 | 'error' => true,
23 | ]
24 | );
25 |
26 | exit;
27 | }
28 |
29 | require APPROOT . '/vendor/autoload.php';
30 |
31 | try {
32 | (new \Joomla\StatsServer\Kernel\WebKernel())->run();
33 | } catch (\Throwable $throwable) {
34 | error_log($throwable);
35 |
36 | if (!headers_sent()) {
37 | header('HTTP/1.1 500 Internal Server Error', null, 500);
38 | header('Content-Type: application/json; charset=utf-8');
39 | }
40 |
41 | echo \json_encode(
42 | [
43 | 'code' => $throwable->getCode(),
44 | 'message' => 'An error occurred while executing the application: ' . $throwable->getMessage(),
45 | 'error' => true,
46 | ]
47 | );
48 |
49 | exit;
50 | }
51 |
--------------------------------------------------------------------------------