Author: Shivam Mathur (shivammathur)
Date: 2025-02-04T15:47:35+05:30

Commit: 
https://github.com/php/web-downloads/commit/676f111092dd99e605497b1a421d68e13399c282
Raw diff: 
https://github.com/php/web-downloads/commit/676f111092dd99e605497b1a421d68e13399c282.diff

Add a command to generate listing

Add support to do DI in commands

Fix autoloading in tests

Changed paths:
  A  src/Actions/GetListing.php
  A  src/Actions/UpdateReleasesJson.php
  A  src/Console/Command/GenerateListingCommand.php
  A  tests/Actions/GetListingTest.php
  A  tests/Actions/UpdateReleasesJsonTest.php
  A  tests/Console/Command/GenerateListingCommandTest.php
  M  phpunit.xml.dist
  M  runner.php
  M  src/Console/Command.php
  M  src/Console/Command/PhpCommand.php
  M  tests/CommandTest.php
  M  tests/Console/Command/PhpCommandTest.php


Diff:

diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 659c34b..c4916f0 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
          xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
-         bootstrap="vendor/autoload.php"
+         bootstrap="autoloader.php"
          cacheDirectory=".phpunit.cache"
          executionOrder="depends,defects"
          shortenArraysForExportThreshold="10"
diff --git a/runner.php b/runner.php
index ca3638d..970cb9f 100644
--- a/runner.php
+++ b/runner.php
@@ -27,7 +27,8 @@ function discoverCommands(string $directory, $argc, $argv): 
array
         if ($file->isFile() && $file->getExtension() === 'php') {
             $className = getClassName($directory, $file);
             if (is_subclass_of($className, Command::class)) {
-                $instance = new $className($argc, $argv);
+                $instance = resolve($className);
+                $instance->setCliArguments($argc, $argv);
                 $commands[$instance->getSignature()] = $instance;
             }
         }
@@ -35,6 +36,28 @@ function discoverCommands(string $directory, $argc, $argv): 
array
     return $commands;
 }
 
+function resolve(string $className) {
+    $reflection = new ReflectionClass($className);
+    $constructor = $reflection->getConstructor();
+    if (!$constructor) {
+        return new $className;
+    }
+    $parameters = $constructor->getParameters();
+    $dependencies = [];
+    foreach ($parameters as $parameter) {
+        $type = $parameter->getType();
+        if ($type && !$type->isBuiltin()) {
+            $dependencyClass = $type->getName();
+            $dependencies[] = resolve($dependencyClass);
+        } elseif ($parameter->isDefaultValueAvailable()) {
+            $dependencies[] = $parameter->getDefaultValue();
+        } else {
+            throw new Exception("Cannot resolve dependency: " . 
$parameter->getName());
+        }
+    }
+    return $reflection->newInstanceArgs($dependencies);
+}
+
 function listCommands(array $commands): void
 {
     echo "Available commands:\n";
diff --git a/src/Actions/GetListing.php b/src/Actions/GetListing.php
new file mode 100644
index 0000000..c36e2dc
--- /dev/null
+++ b/src/Actions/GetListing.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace App\Actions;
+
+class GetListing
+{
+    public function handle(string $directory): array
+    {
+        $builds = glob($directory . '/php-[678].*[0-9]-latest.zip');
+        if (empty($builds)) {
+            $builds = glob($directory . '/php-[678].*[0-9].zip');
+        }
+
+        $releases = [];
+        $sha256sums = $this->getSha256Sums($directory);
+        foreach ($builds as $file) {
+            $file_ori = $file;
+            $mtime = date('Y-M-d H:i:s', filemtime($file));
+
+            $parts = $this->parseFileName(basename($file));
+            $key = ($parts['nts'] ? 'nts-' : 'ts-') . $parts['vc'] . '-' . 
$parts['arch'];
+            $version_short = $parts['version_short'];
+            if (!isset($releases['version'])) {
+                $releases[$version_short]['version'] = $parts['version'];
+            }
+            $releases[$version_short][$key]['mtime'] = $mtime;
+            $releases[$version_short][$key]['zip'] = [
+                'path' => basename($file_ori),
+                'size' => $this->bytes2string(filesize($file_ori)),
+                'sha256' => $sha256sums[strtolower(basename($file_ori))]
+            ];
+            $namingPattern = $parts['version'] . ($parts['nts'] ? '-' . 
$parts['nts'] : '') . '-Win32-' . $parts['vc'] . '-' . $parts['arch'] . 
($parts['ts'] ? '-' . $parts['ts'] : '');
+            $build_types = [
+                'source' => 'php-' . $parts['version'] . '-src.zip',
+                'debug_pack' => 'php-debug-pack-' . $namingPattern . '.zip',
+                'devel_pack' => 'php-devel-pack-' . $namingPattern . '.zip',
+                'installer' => 'php-' . $namingPattern . '.msi',
+                'test_pack' => 'php-test-pack-' . $parts['version'] . '.zip',
+            ];
+            foreach ($build_types as $type => $fileName) {
+                $filePath = $directory . '/' . $fileName;
+                if (file_exists($filePath)) {
+                    if(in_array($type, ['test_pack', 'source'])) {
+                        $releases[$version_short][$type] = [
+                            'path' => $fileName,
+                            'size' => $this->bytes2string(filesize($filePath)),
+                            'sha256' => 
$sha256sums[strtolower(basename($file_ori))]
+                        ];
+                    } else {
+                        $releases[$version_short][$key][$type] = [
+                            'path' => $fileName,
+                            'size' => $this->bytes2string(filesize($filePath)),
+                            'sha256' => 
$sha256sums[strtolower(basename($file_ori))]
+                        ];
+                    }
+                }
+            }
+        }
+        return $releases;
+    }
+
+    public function getSha256Sums($directory): array
+    {
+        $result = [];
+        if(!file_exists("$directory/sha256sum.txt")) {
+            file_put_contents("$directory/sha256sum.txt", '');
+        }
+        $sha_file = fopen("$directory/sha256sum.txt", 'w');
+        foreach (scandir($directory) as $filename) {
+            if (pathinfo($filename, PATHINFO_EXTENSION) !== 'zip') {
+                continue;
+            }
+            $sha256 = hash_file('sha256', "$directory/$filename");
+            fwrite($sha_file, "$sha256 *$filename\n");
+            $result[strtolower(basename($filename))] = $sha256;
+        }
+        fclose($sha_file);
+        return $result;
+    }
+
+    public function bytes2string(int $size): string
+    {
+        $sizes = ['YB', 'ZB', 'EB', 'PB', 'TB', 'GB', 'MB', 'kB', 'B'];
+
+        $total = count($sizes);
+
+        while ($total-- && $size > 1024) $size /= 1024;
+
+        return round($size, 2) . $sizes[$total];
+    }
+
+    public function parseFileName($fileName): array
+    {
+        $fileName = str_replace(['-Win32', '.zip'], ['', ''], $fileName);
+
+        $parts = explode('-', $fileName);
+        if (is_numeric($parts[2]) || $parts[2] == 'dev') {
+            $version = $parts[1] . '-' . $parts[2];
+            $nts = $parts[3] == 'nts' ? 'nts' : false;
+            if ($nts) {
+                $vc = $parts[4];
+                $arch = $parts[5];
+            } else {
+                $vc = $parts[3];
+                $arch = $parts[4];
+            }
+        } elseif ($parts[2] == 'nts') {
+            $nts = 'nts';
+            $version = $parts[1];
+            $vc = $parts[3];
+            $arch = $parts[4];
+        } else {
+            $nts = false;
+            $version = $parts[1];
+            $vc = $parts[2];
+            $arch = $parts[3];
+        }
+        if (is_numeric($vc)) {
+            $vc = 'VC6';
+            $arch = 'x86';
+        }
+        $t = count($parts) - 1;
+        $ts = is_numeric($parts[$t]) ? $parts[$t] : false;
+
+        return [
+            'version' => $version,
+            'version_short' => substr($version, 0, 3),
+            'nts' => $nts,
+            'vc' => $vc,
+            'arch' => $arch,
+            'ts' => $ts
+        ];
+    }
+}
\ No newline at end of file
diff --git a/src/Actions/UpdateReleasesJson.php 
b/src/Actions/UpdateReleasesJson.php
new file mode 100644
index 0000000..faad68e
--- /dev/null
+++ b/src/Actions/UpdateReleasesJson.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Actions;
+
+use DateTimeImmutable;
+use Exception;
+
+class UpdateReleasesJson
+{
+    /**
+     * @throws Exception
+     */
+    public function handle(array $releases, string $directory): void
+    {
+        try {
+            foreach ($releases as &$release) {
+                foreach ($release as &$build_type) {
+                    if (!is_array($build_type) || 
!isset($build_type['mtime'])) {
+                        continue;
+                    }
+
+                    $date = new DateTimeImmutable($build_type['mtime']);
+                    $build_type['mtime'] = $date->format('c');
+                }
+                unset($build_type);
+            }
+            unset($release);
+            file_put_contents(
+                $directory . '/releases.json',
+                json_encode($releases, JSON_PRETTY_PRINT)
+            );
+        } catch (Exception $exception) {
+            throw new Exception('Failed to generate releases.json: ' . 
$exception->getMessage());
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Console/Command.php b/src/Console/Command.php
index 56d9ac6..a59f9d1 100644
--- a/src/Console/Command.php
+++ b/src/Console/Command.php
@@ -9,21 +9,19 @@ abstract class Command
     public const INVALID = 2;
 
     protected string $signature = '';
-
     protected string $description = '';
+    protected array $arguments = [];
+    protected array $options = [];
 
-    public function __construct(
-        protected ?int   $argc = null,
-        protected ?array $argv = null,
-        protected array  $arguments = [],
-        protected array  $options = [],
-    ) {
-        if ($argc !== null && $argv !== null) {
-            $this->parse($argc, $argv);
-        }
+    public function __construct() {
+        //
+    }
+
+    public function setCliArguments(int $argc, array $argv): void {
+        $this->parse($argc, $argv);
     }
-    abstract public function handle();
 
+    abstract public function handle(): int;
 
     private function parse($argc, $argv): void {
         $pattern = '/\{(\w+)}|\{--(\w+)}/';
diff --git a/src/Console/Command/GenerateListingCommand.php 
b/src/Console/Command/GenerateListingCommand.php
new file mode 100644
index 0000000..ecae3b5
--- /dev/null
+++ b/src/Console/Command/GenerateListingCommand.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Console\Command;
+
+use App\Actions\GetListing;
+use App\Actions\UpdateReleasesJson;
+use App\Console\Command;
+use Exception;
+
+class GenerateListingCommand extends Command
+{
+    protected string $signature = 'php:add --directory=';
+    protected string $description = 'Generate Listing for PHP builds in a 
directory';
+
+    public function __construct(
+        protected GetListing $generateListing,
+        protected UpdateReleasesJson $updateReleasesJson,
+    ) {
+        parent::__construct();
+    }
+
+    public function handle(): int
+    {
+        try {
+            $directory = $this->getOption('directory');
+            if (!$directory) {
+                throw new Exception('Directory is required');
+            }
+
+            $releases = $this->generateListing->handle($directory);
+            $this->updateReleasesJson->handle($releases, $directory);
+            return Command::SUCCESS;
+        } catch (Exception $e) {
+            echo $e->getMessage();
+            return Command::FAILURE;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Console/Command/PhpCommand.php 
b/src/Console/Command/PhpCommand.php
index 2d98190..86f8044 100644
--- a/src/Console/Command/PhpCommand.php
+++ b/src/Console/Command/PhpCommand.php
@@ -2,9 +2,10 @@
 
 namespace App\Console\Command;
 
+use App\Actions\GetListing;
+use App\Actions\UpdateReleasesJson;
 use App\Console\Command;
 use App\Helpers\Helpers;
-use DateTimeImmutable;
 use Exception;
 use ZipArchive;
 
@@ -15,6 +16,13 @@ class PhpCommand extends Command
 
     protected ?string $baseDirectory = null;
 
+    public function __construct(
+        protected GetListing $generateListing,
+        protected UpdateReleasesJson $updateReleasesJson,
+    ) {
+        parent::__construct();
+    }
+
     public function handle(): int
     {
         try {
@@ -71,7 +79,12 @@ public function handle(): int
 
                 $this->moveBuild($tempDirectory, $destinationDirectory);
 
-                $this->generateListing($destinationDirectory);
+                $releases = 
$this->generateListing->handle($destinationDirectory);
+
+                $this->updateReleasesJson->handle($releases, 
$destinationDirectory);
+                if ($destinationDirectory === $this->baseDirectory . 
'/releases') {
+                    $this->updateLatestBuilds($releases, 
$destinationDirectory);
+                }
 
                 (new Helpers)->rmdirr($tempDirectory);
 
@@ -156,96 +169,6 @@ private function getFileVersion(string $file): string
         return str_replace('.zip', '', $parts[0]);
     }
 
-    /**
-     * @throws Exception
-     */
-    private function generateListing(string $directory): void
-    {
-        $builds = glob($directory . '/php-[678].*[0-9]-latest.zip');
-        if (empty($builds)) {
-            $builds = glob($directory . '/php-[678].*[0-9].zip');
-        }
-
-        $releases = [];
-        $sha256sums = $this->getSha256Sums($directory);
-        foreach ($builds as $file) {
-            $file_ori = $file;
-            $mtime = date('Y-M-d H:i:s', filemtime($file));
-
-            $parts = $this->parseFileName(basename($file));
-            $key = ($parts['nts'] ? 'nts-' : 'ts-') . $parts['vc'] . '-' . 
$parts['arch'];
-            $version_short = $parts['version_short'];
-            if (!isset($releases['version'])) {
-                $releases[$version_short]['version'] = $parts['version'];
-            }
-            $releases[$version_short][$key]['mtime'] = $mtime;
-            $releases[$version_short][$key]['zip'] = [
-                'path' => basename($file_ori),
-                'size' => $this->bytes2string(filesize($file_ori)),
-                'sha256' => $sha256sums[strtolower(basename($file_ori))]
-            ];
-            $namingPattern = $parts['version'] . ($parts['nts'] ? '-' . 
$parts['nts'] : '') . '-Win32-' . $parts['vc'] . '-' . $parts['arch'] . 
($parts['ts'] ? '-' . $parts['ts'] : '');
-            $build_types = [
-                'source' => 'php-' . $parts['version'] . '-src.zip',
-                'debug_pack' => 'php-debug-pack-' . $namingPattern . '.zip',
-                'devel_pack' => 'php-devel-pack-' . $namingPattern . '.zip',
-                'installer' => 'php-' . $namingPattern . '.msi',
-                'test_pack' => 'php-test-pack-' . $parts['version'] . '.zip',
-            ];
-            foreach ($build_types as $type => $fileName) {
-                $filePath = $directory . '/' . $fileName;
-                if (file_exists($filePath)) {
-                    if(in_array($type, ['test_pack', 'source'])) {
-                        $releases[$version_short][$type] = [
-                            'path' => $fileName,
-                            'size' => $this->bytes2string(filesize($filePath)),
-                            'sha256' => 
$sha256sums[strtolower(basename($file_ori))]
-                        ];
-                    } else {
-                        $releases[$version_short][$key][$type] = [
-                            'path' => $fileName,
-                            'size' => $this->bytes2string(filesize($filePath)),
-                            'sha256' => 
$sha256sums[strtolower(basename($file_ori))]
-                        ];
-                    }
-                }
-            }
-        }
-
-        $this->updateReleasesJson($releases, $directory);
-        if ($directory === $this->baseDirectory . '/releases') {
-            $this->updateLatestBuilds($releases, $directory);
-        }
-    }
-
-    /**
-     * @throws Exception
-     */
-    private function updateReleasesJson(array $releases, string $directory): 
void
-    {
-        foreach ($releases as &$release) {
-            foreach ($release as &$build_type) {
-                if (!is_array($build_type) || !isset($build_type['mtime'])) {
-                    continue;
-                }
-
-                try {
-                    $date = new DateTimeImmutable($build_type['mtime']);
-                    $build_type['mtime'] = $date->format('c');
-                } catch (Exception $exception) {
-                    throw new Exception('Failed to generate releases.json: ' . 
$exception->getMessage());
-                }
-            }
-            unset($build_type);
-        }
-        unset($release);
-
-        file_put_contents(
-            $directory . '/releases.json',
-            json_encode($releases, JSON_PRETTY_PRINT)
-        );
-    }
-
     private function updateLatestBuilds($releases, $directory): void
     {
         if(!is_dir($directory . '/latest')) {
@@ -262,77 +185,4 @@ private function updateLatestBuilds($releases, 
$directory): void
             });
         }
     }
-
-    private function getSha256Sums($directory): array
-    {
-        $result = [];
-        if(!file_exists("$directory/sha256sum.txt")) {
-            file_put_contents("$directory/sha256sum.txt", '');
-        }
-        $sha_file = fopen("$directory/sha256sum.txt", 'w');
-        foreach (scandir($directory) as $filename) {
-            if (pathinfo($filename, PATHINFO_EXTENSION) !== 'zip') {
-                continue;
-            }
-            $sha256 = hash_file('sha256', "$directory/$filename");
-            fwrite($sha_file, "$sha256 *$filename\n");
-            $result[strtolower(basename($filename))] = $sha256;
-        }
-        fclose($sha_file);
-        return $result;
-    }
-
-    private function bytes2string(int $size): string
-    {
-        $sizes = ['YB', 'ZB', 'EB', 'PB', 'TB', 'GB', 'MB', 'kB', 'B'];
-
-        $total = count($sizes);
-
-        while ($total-- && $size > 1024) $size /= 1024;
-
-        return round($size, 2) . $sizes[$total];
-    }
-
-    private function parseFileName($fileName): array
-    {
-        $fileName = str_replace(['-Win32', '.zip'], ['', ''], $fileName);
-
-        $parts = explode('-', $fileName);
-        if (is_numeric($parts[2]) || $parts[2] == 'dev') {
-            $version = $parts[1] . '-' . $parts[2];
-            $nts = $parts[3] == 'nts' ? 'nts' : false;
-            if ($nts) {
-                $vc = $parts[4];
-                $arch = $parts[5];
-            } else {
-                $vc = $parts[3];
-                $arch = $parts[4];
-            }
-        } elseif ($parts[2] == 'nts') {
-            $nts = 'nts';
-            $version = $parts[1];
-            $vc = $parts[3];
-            $arch = $parts[4];
-        } else {
-            $nts = false;
-            $version = $parts[1];
-            $vc = $parts[2];
-            $arch = $parts[3];
-        }
-        if (is_numeric($vc)) {
-            $vc = 'VC6';
-            $arch = 'x86';
-        }
-        $t = count($parts) - 1;
-        $ts = is_numeric($parts[$t]) ? $parts[$t] : false;
-
-        return [
-            'version' => $version,
-            'version_short' => substr($version, 0, 3),
-            'nts' => $nts,
-            'vc' => $vc,
-            'arch' => $arch,
-            'ts' => $ts
-        ];
-    }
 }
\ No newline at end of file
diff --git a/tests/Actions/GetListingTest.php b/tests/Actions/GetListingTest.php
new file mode 100644
index 0000000..985bcf7
--- /dev/null
+++ b/tests/Actions/GetListingTest.php
@@ -0,0 +1,205 @@
+<?php
+
+namespace Actions;
+
+use App\Actions\GetListing;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+class GetListingTest extends TestCase
+{
+    private string $tempDir;
+    private GetListing $getListing;
+
+    protected function setUp(): void
+    {
+        $this->tempDir = sys_get_temp_dir() . '/get_listing_test_' . uniqid();
+        if (!mkdir($this->tempDir) && !is_dir($this->tempDir)) {
+            throw new RuntimeException(sprintf('Directory "%s" was not 
created', $this->tempDir));
+        }
+        $this->getListing = new GetListing();
+    }
+
+    protected function tearDown(): void
+    {
+        (new Helpers())->rmdirr($this->tempDir);
+    }
+
+    public static function bytes2StringProvider(): array
+    {
+        return [
+            [100, "100B"],
+            [1024, "1024B"],
+            [1025, "1kB"],
+            [5000, "4.88kB"],
+            [1048576, "1024kB"],
+            [1048577, "1MB"],
+            [1073741824, "1024MB"],
+            [1073741825, "1GB"],
+            [1099511627776, "1024GB"],
+            [1099511627777, "1TB"],
+        ];
+    }
+
+    public static function parseFileNameProvider(): array
+    {
+        return [
+            'with nts' => [
+                'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+                [
+                    'version'       => '7.4.0',
+                    'version_short' => '7.4',
+                    'nts'           => 'nts',
+                    'vc'            => 'VC15',
+                    'arch'          => 'x64',
+                    'ts'            => false,
+                ],
+            ],
+            'without nts' => [
+                'php-7.4.0-Win32-VC15-x64-latest.zip',
+                [
+                    'version'       => '7.4.0',
+                    'version_short' => '7.4',
+                    'nts'           => false,
+                    'vc'            => 'VC15',
+                    'arch'          => 'x64',
+                    'ts'            => false,
+                ],
+            ],
+            'with numeric vc' => [
+                'php-5.6.0-nts-Win32-7-x86-latest.zip',
+                [
+                    'version'       => '5.6.0',
+                    'version_short' => '5.6',
+                    'nts'           => 'nts',
+                    'vc'            => 'VC6',
+                    'arch'          => 'x86',
+                    'ts'            => false,
+                ],
+            ],
+        ];
+    }
+
+    #[DataProvider('bytes2StringProvider')]
+    public function testBytes2String(int $bytes, string $expected): void
+    {
+        $result = $this->getListing->bytes2string($bytes);
+        $this->assertEquals($expected, $result);
+    }
+    #[DataProvider('parseFileNameProvider')]
+    public function testParseFileName(string $fileName, array $expected): void
+    {
+        $parts = $this->getListing->parseFileName($fileName);
+        $this->assertEquals($expected, $parts);
+    }
+
+    public function testGetSha256SumsCreatesFileAndReturnsHashes(): void
+    {
+        $dummyZip = $this->tempDir . '/dummy.zip';
+        $content = "dummy content";
+        file_put_contents($dummyZip, $content);
+
+        $sums = $this->getListing->getSha256Sums($this->tempDir);
+
+        $key = strtolower(basename($dummyZip));
+        $expectedHash = hash_file('sha256', $dummyZip);
+
+        $this->assertArrayHasKey($key, $sums);
+        $this->assertEquals($expectedHash, $sums[$key]);
+
+        $shaFile = $this->tempDir . '/sha256sum.txt';
+        $this->assertFileExists($shaFile);
+        $this->assertNotEmpty(file_get_contents($shaFile));
+    }
+
+    public function testHandleWithNoMatchingFiles(): void
+    {
+        $result = $this->getListing->handle($this->tempDir);
+        $this->assertEmpty($result, "Expected an empty result when no build 
files are present.");
+    }
+
+    public function testHandleWithMatchingFiles(): void
+    {
+        $mainBuildFile = $this->tempDir . 
'/php-7.4.0-nts-Win32-VC15-x64-latest.zip';
+        file_put_contents($mainBuildFile, 'build content');
+        $fixedTime = 1609459200; // 2021-01-01 00:00:00
+        touch($mainBuildFile, $fixedTime);
+
+        $sourceFile      = $this->tempDir . '/php-7.4.0-src.zip';
+        $debugPackFile   = $this->tempDir . 
'/php-debug-pack-7.4.0-nts-Win32-VC15-x64.zip';
+        $develPackFile   = $this->tempDir . 
'/php-devel-pack-7.4.0-nts-Win32-VC15-x64.zip';
+        $installerFile   = $this->tempDir . 
'/php-7.4.0-nts-Win32-VC15-x64.msi';
+        $testPackFile    = $this->tempDir . '/php-test-pack-7.4.0.zip';
+
+        file_put_contents($sourceFile, 'source content');
+        file_put_contents($debugPackFile, 'debug content');
+        file_put_contents($develPackFile, 'devel content');
+        file_put_contents($installerFile, 'installer content');
+        file_put_contents($testPackFile, 'test content');
+
+        $result = $this->getListing->handle($this->tempDir);
+        $versionShortKey = '7.4';
+        $buildKey = 'nts-VC15-x64';
+
+        $this->assertArrayHasKey($versionShortKey, $result);
+        $versionData = $result[$versionShortKey];
+
+        $this->assertArrayHasKey('version', $versionData);
+        $this->assertEquals('7.4.0', $versionData['version']);
+
+        $this->assertArrayHasKey($buildKey, $versionData);
+        $buildDetails = $versionData[$buildKey];
+
+        $expectedMtime = date('Y-M-d H:i:s', filemtime($mainBuildFile));
+        $this->assertEquals($expectedMtime, $buildDetails['mtime']);
+
+        $this->assertArrayHasKey('zip', $buildDetails);
+        $zipInfo = $buildDetails['zip'];
+        $this->assertEquals(basename($mainBuildFile), $zipInfo['path']);
+
+        $expectedZipSize = 
$this->getListing->bytes2string(filesize($mainBuildFile));
+        $this->assertEquals($expectedZipSize, $zipInfo['size']);
+
+        $expectedSha256 = hash_file('sha256', $mainBuildFile);
+        $this->assertEquals($expectedSha256, $zipInfo['sha256']);
+
+        $this->assertArrayHasKey('source', $versionData);
+        $sourceInfo = $versionData['source'];
+        $this->assertEquals(basename($sourceFile), $sourceInfo['path']);
+        $expectedSourceSize = 
$this->getListing->bytes2string(filesize($sourceFile));
+        $this->assertEquals($expectedSourceSize, $sourceInfo['size']);
+        $this->assertEquals($expectedSha256, $sourceInfo['sha256']);
+
+        $this->assertArrayHasKey('test_pack', $versionData);
+        $testPackInfo = $versionData['test_pack'];
+        $this->assertEquals(basename($testPackFile), $testPackInfo['path']);
+        $expectedTestPackSize = 
$this->getListing->bytes2string(filesize($testPackFile));
+        $this->assertEquals($expectedTestPackSize, $testPackInfo['size']);
+        $this->assertEquals($expectedSha256, $testPackInfo['sha256']);
+
+        $this->assertArrayHasKey('debug_pack', $buildDetails);
+        $debugInfo = $buildDetails['debug_pack'];
+        $this->assertEquals(basename($debugPackFile), $debugInfo['path']);
+        $expectedDebugSize = 
$this->getListing->bytes2string(filesize($debugPackFile));
+        $this->assertEquals($expectedDebugSize, $debugInfo['size']);
+        $this->assertEquals($expectedSha256, $debugInfo['sha256']);
+
+        $this->assertArrayHasKey('devel_pack', $buildDetails);
+        $develInfo = $buildDetails['devel_pack'];
+        $this->assertEquals(basename($develPackFile), $develInfo['path']);
+        $expectedDevelSize = 
$this->getListing->bytes2string(filesize($develPackFile));
+        $this->assertEquals($expectedDevelSize, $develInfo['size']);
+        $this->assertEquals($expectedSha256, $develInfo['sha256']);
+
+        $this->assertArrayHasKey('installer', $buildDetails);
+        $installerInfo = $buildDetails['installer'];
+        $this->assertEquals(basename($installerFile), $installerInfo['path']);
+        $expectedInstallerSize = 
$this->getListing->bytes2string(filesize($installerFile));
+        $this->assertEquals($expectedInstallerSize, $installerInfo['size']);
+        $this->assertEquals($expectedSha256, $installerInfo['sha256']);
+
+        $this->assertFileExists($this->tempDir . '/sha256sum.txt');
+    }
+}
diff --git a/tests/Actions/UpdateReleasesJsonTest.php 
b/tests/Actions/UpdateReleasesJsonTest.php
new file mode 100644
index 0000000..12093c6
--- /dev/null
+++ b/tests/Actions/UpdateReleasesJsonTest.php
@@ -0,0 +1,138 @@
+<?php
+declare(strict_types=1);
+
+namespace Actions;
+
+use App\Actions\UpdateReleasesJson;
+use App\Helpers\Helpers;
+use DateTimeImmutable;
+use Exception;
+use PHPUnit\Framework\TestCase;
+
+class UpdateReleasesJsonTest extends TestCase
+{
+    private string $tempDir;
+
+    /**
+     * @throws Exception
+     */
+    protected function setUp(): void
+    {
+        date_default_timezone_set('UTC');
+        $this->tempDir = sys_get_temp_dir() . '/update_releases_test_' . 
uniqid();
+        if (!mkdir($this->tempDir, 0777, true) && !is_dir($this->tempDir)) {
+            throw new Exception(sprintf('Directory "%s" was not created', 
$this->tempDir));
+        }
+    }
+
+    protected function tearDown(): void
+    {
+        (new Helpers())->rmdirr($this->tempDir);
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function testHandleValidReleases(): void
+    {
+        $releases = [
+            '7.4' => [
+                'version' => '7.4.0',
+                'nts-VC15-x64' => [
+                    'mtime'      => "2023-01-01 10:00:00",
+                    'zip'        => [
+                        'path'   => 'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+                        'size'   => '12kB',
+                        'sha256' => 'abcdef'
+                    ],
+                    'debug_pack' => [
+                        'mtime'      => "2023-01-01 11:00:00",
+                        'path'       => 
'php-debug-pack-7.4.0-nts-Win32-VC15-x64.zip',
+                        'size'       => '3kB',
+                        'sha256'     => '123456'
+                    ],
+                ],
+                'source' => [
+                    'path'   => 'php-7.4.0-src.zip',
+                    'size'   => '5MB',
+                    'sha256' => '987654'
+                ],
+            ],
+        ];
+
+        $updater = new UpdateReleasesJson();
+        $updater->handle($releases, $this->tempDir);
+
+        $jsonFile = $this->tempDir . '/releases.json';
+        $this->assertFileExists($jsonFile);
+
+        $jsonData = json_decode(file_get_contents($jsonFile), true);
+        $this->assertNotNull($jsonData, 'Decoded JSON should not be null.');
+
+        $expectedDate = (new DateTimeImmutable("2023-01-01 
10:00:00"))->format('c');
+
+        $this->assertEquals(
+            $expectedDate,
+            $jsonData['7.4']['nts-VC15-x64']['mtime'],
+            'Main build mtime should be in ISO 8601 format.'
+        );
+
+        $this->assertArrayHasKey('source', $jsonData['7.4']);
+        $this->assertArrayNotHasKey('mtime', $jsonData['7.4']['source']);
+    }
+
+    public function testHandleWithInvalidMtimeThrowsException(): void
+    {
+        $this->expectException(Exception::class);
+        $this->expectExceptionMessage('Failed to generate releases.json:');
+
+        $releases = [
+            '7.4' => [
+                'nts-VC15-x64' => [
+                    'mtime' => "invalid date string",
+                    'zip'   => [
+                        'path'   => 'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+                        'size'   => '12kB',
+                        'sha256' => 'abcdef'
+                    ],
+                ],
+            ],
+        ];
+
+        (new UpdateReleasesJson())->handle($releases, $this->tempDir);
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function testHandleWithNoMtime(): void
+    {
+        $releases = [
+            '7.4' => [
+                'nts-VC15-x64' => [
+                    'zip' => [
+                        'path'   => 'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+                        'size'   => '12kB',
+                        'sha256' => 'abcdef'
+                    ],
+                ],
+            ],
+        ];
+
+        $updater = new UpdateReleasesJson();
+        $updater->handle($releases, $this->tempDir);
+
+        $jsonFile = $this->tempDir . '/releases.json';
+        $this->assertFileExists($jsonFile);
+
+        $jsonData = json_decode(file_get_contents($jsonFile), true);
+        $this->assertNotNull($jsonData, 'Decoded JSON should not be null.');
+
+        $this->assertArrayHasKey('zip', $jsonData['7.4']['nts-VC15-x64']);
+        $this->assertEquals(
+            'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+            $jsonData['7.4']['nts-VC15-x64']['zip']['path']
+        );
+        $this->assertArrayNotHasKey('mtime', $jsonData['7.4']['nts-VC15-x64']);
+    }
+}
diff --git a/tests/CommandTest.php b/tests/CommandTest.php
index 3d0f799..ea3a03d 100644
--- a/tests/CommandTest.php
+++ b/tests/CommandTest.php
@@ -13,7 +13,8 @@ public function handle(): int {
 class CommandTest extends TestCase {
     public function testParseArgumentsAndOptions() {
         $argv = ["script.php", "value", "--option=optValue"];
-        $command = new TestCommand(count($argv), $argv);
+        $command = new TestCommand();
+        $command->setCliArguments(count($argv), $argv);
 
         $this->assertEquals("value", $command->getArgument("arg"), "Argument 
parsing failed.");
         $this->assertEquals("optValue", $command->getOption("option"), "Option 
parsing failed.");
diff --git a/tests/Console/Command/GenerateListingCommandTest.php 
b/tests/Console/Command/GenerateListingCommandTest.php
new file mode 100644
index 0000000..ec53b67
--- /dev/null
+++ b/tests/Console/Command/GenerateListingCommandTest.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Console\Command;
+
+use App\Actions\GetListing;
+use App\Actions\UpdateReleasesJson;
+use App\Console\Command as BaseCommand;
+use App\Console\Command\GenerateListingCommand;
+use Exception;
+use PHPUnit\Framework\MockObject\Exception as MockObjectException;
+use PHPUnit\Framework\TestCase;
+
+class GenerateListingCommandTest extends TestCase
+{
+    /**
+     * @throws MockObjectException
+     */
+    public function testHandleWithoutDirectory(): void
+    {
+        $getListing = $this->createMock(GetListing::class);
+        $updateReleasesJson = $this->createMock(UpdateReleasesJson::class);
+        $command = new GenerateListingCommand($getListing, 
$updateReleasesJson);
+        $argv = ['script.php', 'php:add'];
+        $argc = count($argv);
+        $command->setCliArguments($argc, $argv);
+
+        ob_start();
+        $result = $command->handle();
+        $output = ob_get_clean();
+
+        $this->assertStringContainsString('Directory is required', $output);
+        $this->assertEquals(BaseCommand::FAILURE, $result);
+    }
+
+    /**
+     * @throws MockObjectException
+     */
+    public function testHandleSuccess(): void
+    {
+        $directory = '/some/directory';
+        $dummyReleases = ['dummy' => 'value'];
+
+        $getListing = $this->createMock(GetListing::class);
+        $updateReleasesJson = $this->createMock(UpdateReleasesJson::class);
+
+        $getListing->expects($this->once())
+            ->method('handle')
+            ->with($directory)
+            ->willReturn($dummyReleases);
+
+        $updateReleasesJson->expects($this->once())
+            ->method('handle')
+            ->with($dummyReleases, $directory);
+
+        $command = new GenerateListingCommand($getListing, 
$updateReleasesJson);
+
+        $argv = ['script.php', 'php:add', '--directory=' . $directory];
+        $argc = count($argv);
+        $command->setCliArguments($argc, $argv);
+
+        $result = $command->handle();
+
+        $this->assertEquals(BaseCommand::SUCCESS, $result);
+    }
+
+    /**
+     * @throws MockObjectException
+     */
+    public function testHandleWhenExceptionThrown(): void
+    {
+        $directory = '/some/directory';
+
+        $getListing = $this->createMock(GetListing::class);
+        $updateReleasesJson = $this->createMock(UpdateReleasesJson::class);
+
+        $getListing->expects($this->once())
+            ->method('handle')
+            ->with($directory)
+            ->will($this->throwException(new Exception("Test exception")));
+
+        $updateReleasesJson->expects($this->never())
+            ->method('handle');
+
+        $command = new GenerateListingCommand($getListing, 
$updateReleasesJson);
+        $argv = ['script.php', 'php:add', '--directory=' . $directory];
+        $argc = count($argv);
+        $command->setCliArguments($argc, $argv);
+
+        ob_start();
+        $result = $command->handle();
+        $output = ob_get_clean();
+
+        $this->assertStringContainsString("Test exception", $output);
+        $this->assertEquals(BaseCommand::FAILURE, $result);
+    }
+}
diff --git a/tests/Console/Command/PhpCommandTest.php 
b/tests/Console/Command/PhpCommandTest.php
index cddce3b..5178423 100644
--- a/tests/Console/Command/PhpCommandTest.php
+++ b/tests/Console/Command/PhpCommandTest.php
@@ -2,6 +2,8 @@
 
 namespace Console\Command;
 
+use App\Actions\GetListing;
+use App\Actions\UpdateReleasesJson;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\TestCase;
 use App\Console\Command\PhpCommand;
@@ -94,7 +96,7 @@ private function stageBuilds(array $phpZips, $zipPath): void
     #[DataProvider('buildsProvider')]
     public function testCommandHandlesSuccessfulExecution(array $phpZips): void
     {
-        $command = new PhpCommand();
+        $command = new PhpCommand((new GetListing()), (new 
UpdateReleasesJson()));
         $command->setOption('base-directory', $this->baseDirectory);
         $command->setOption('builds-directory', $this->buildsDirectory);
 
@@ -110,7 +112,7 @@ public function testCommandHandlesSuccessfulExecution(array 
$phpZips): void
 
     public function testCommandHandlerWithMissingTestPackZip(): void
     {
-        $command = new PhpCommand();
+        $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
         $command->setOption('base-directory', $this->baseDirectory);
         $command->setOption('builds-directory', $this->buildsDirectory);
 
@@ -124,7 +126,7 @@ public function testCommandHandlerWithMissingTestPackZip(): 
void
 
     public function testCommandHandlesMissingBaseDirectory(): void
     {
-        $command = new PhpCommand();
+        $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
         ob_start();
         $result = $command->handle();
         $output = ob_get_clean();
@@ -136,7 +138,7 @@ public function testFailsToOpenZip(): void
     {
         $zipPath = $this->buildsDirectory . '/php/broken.zip';
         file_put_contents($zipPath, "invalid zip content");
-        $command = new PhpCommand();
+        $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
         $command->setOption('base-directory', $this->baseDirectory);
         $command->setOption('builds-directory', $this->buildsDirectory);
         ob_start();
@@ -147,7 +149,7 @@ public function testFailsToOpenZip(): void
 
     public function testCleanupAfterCommand(): void
     {
-        $command = new PhpCommand();
+        $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
         $command->setOption('base-directory', $this->baseDirectory);
         $command->setOption('builds-directory', $this->buildsDirectory);
         $command->handle();

Reply via email to