Author: Shivam Mathur (shivammathur)
Date: 2025-09-26T01:34:52+05:30

Commit: 
https://github.com/php/web-downloads/commit/66fe29e20afc67287231bbdf6a8629ae13c3d4c1
Raw diff: 
https://github.com/php/web-downloads/commit/66fe29e20afc67287231bbdf6a8629ae13c3d4c1.diff

Add support to delete and promote series files to stable

Changed paths:
  A  .github/workflows/series-delete.yml
  A  .github/workflows/series-stability.yml
  A  src/Console/Command/SeriesDeleteCommand.php
  A  src/Console/Command/SeriesStabilityCommand.php
  A  src/Http/Controllers/SeriesDeleteController.php
  A  src/Http/Controllers/SeriesStabilityController.php
  A  tests/Console/Command/SeriesDeleteCommandTest.php
  A  tests/Console/Command/SeriesStabilityCommandTest.php
  A  tests/Http/Controllers/SeriesDeleteControllerTest.php
  A  tests/Http/Controllers/SeriesStabilityControllerTest.php
  M  API.md
  M  routes.php


Diff:

diff --git a/.github/workflows/series-delete.yml 
b/.github/workflows/series-delete.yml
new file mode 100644
index 0000000..1d3b605
--- /dev/null
+++ b/.github/workflows/series-delete.yml
@@ -0,0 +1,23 @@
+name: Delete series files
+run-name: Series delete ${{ inputs.php_version }}
+on:
+  workflow_dispatch:
+    inputs:
+      php_version:
+        description: 'PHP Version'
+        required: true
+      vs_version:
+        description: 'VS Version'
+        required: true
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    environment: downloads.php.net
+    steps:
+      - name: Run
+        run: |
+          curl \
+          --request POST \
+          --location https://downloads.php.net/api/series-delete \
+          --header 'Authorization: Bearer ${{ secrets.AUTH_TOKEN }}' \
+          --data '{ "php_version": "${{ inputs.php_version }}", "vs_version": 
"${{ inputs.vs_version }}" }'
diff --git a/.github/workflows/series-stability.yml 
b/.github/workflows/series-stability.yml
new file mode 100644
index 0000000..cfc41c6
--- /dev/null
+++ b/.github/workflows/series-stability.yml
@@ -0,0 +1,23 @@
+name: Promote series files to stable
+run-name: Series stability ${{ inputs.php_version }}
+on:
+  workflow_dispatch:
+    inputs:
+      php_version:
+        description: 'PHP Version'
+        required: true
+      vs_version:
+        description: 'VS Version'
+        required: true
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    environment: downloads.php.net
+    steps:
+      - name: Run
+        run: |
+          curl \
+          --request POST \
+          --location https://downloads.php.net/api/series-stability \
+          --header 'Authorization: Bearer ${{ secrets.AUTH_TOKEN }}' \
+          --data '{ "php_version": "${{ inputs.php_version }}", "vs_version": 
"${{ inputs.vs_version }}" }'
diff --git a/API.md b/API.md
index 0bdcbb8..1bc0735 100644
--- a/API.md
+++ b/API.md
@@ -144,3 +144,53 @@ curl -i -X POST \
         }' \
     https://downloads.php.net/api/series-init
 ```
+
+---
+
+### POST /api/series-delete
+
+- Auth: Required
+- Purpose: Queue deletion of existing series files.
+- Request body (JSON):
+    - `php_version` (string, required): Matches `^(\d+\.\d+|master)$`.
+    - `vs_version` (string, required): Matches `^v[c|s]\d{2}$`.
+- Success: `200 OK`, empty body.
+- Errors: `400` with validation details if input is invalid.
+
+Example
+
+```bash
+curl -i -X POST \
+    -H "Authorization: Bearer $AUTH_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d '{
+            "php_version": "8.3",
+            "vs_version": "vs16"
+        }' \
+    https://downloads.php.net/api/series-delete
+```
+
+---
+
+### POST /api/series-stability
+
+- Auth: Required
+- Purpose: Promote the staging series files to stable.
+- Request body (JSON):
+    - `php_version` (string, required): Matches `^(\d+\.\d+|master)$`.
+    - `vs_version` (string, required): Matches `^v[c|s]\d{2}$`.
+- Success: `200 OK`, empty body.
+- Errors: `400` with validation details if input is invalid.
+
+Example
+
+```bash
+curl -i -X POST \
+    -H "Authorization: Bearer $AUTH_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d '{
+            "php_version": "8.3",
+            "vs_version": "vs16"
+        }' \
+    https://downloads.php.net/api/series-stability
+```
diff --git a/routes.php b/routes.php
index d8a5471..fb590a3 100644
--- a/routes.php
+++ b/routes.php
@@ -3,7 +3,9 @@
 use App\Http\Controllers\IndexController;
 use App\Http\Controllers\PeclController;
 use App\Http\Controllers\PhpController;
+use App\Http\Controllers\SeriesDeleteController;
 use App\Http\Controllers\SeriesInitController;
+use App\Http\Controllers\SeriesStabilityController;
 use App\Http\Controllers\WinlibsController;
 use App\Router;
 
@@ -13,4 +15,6 @@
 $router->registerRoute('/api/winlibs', 'POST', WinlibsController::class, true);
 $router->registerRoute('/api/php', 'POST', PhpController::class, true);
 $router->registerRoute('/api/series-init', 'POST', 
SeriesInitController::class, true);
+$router->registerRoute('/api/series-delete', 'POST', 
SeriesDeleteController::class, true);
+$router->registerRoute('/api/series-stability', 'POST', 
SeriesStabilityController::class, true);
 $router->handleRequest();
diff --git a/src/Console/Command/SeriesDeleteCommand.php 
b/src/Console/Command/SeriesDeleteCommand.php
new file mode 100644
index 0000000..d032dfb
--- /dev/null
+++ b/src/Console/Command/SeriesDeleteCommand.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Console\Command;
+
+use App\Console\Command;
+use Exception;
+
+class SeriesDeleteCommand extends Command
+{
+    protected string $signature = 'series:delete --base-directory= 
--builds-directory=';
+    protected string $description = 'Delete series files for libraries';
+
+    protected ?string $baseDirectory = null;
+
+    public function handle(): int
+    {
+        try {
+            $this->baseDirectory = $this->getOption('base-directory');
+            if (!$this->baseDirectory) {
+                throw new Exception('Base directory is required');
+            }
+
+            $buildsDirectory = $this->getOption('builds-directory');
+            if (!$buildsDirectory) {
+                throw new Exception('Build directory is required');
+            }
+
+            $series_directory = $buildsDirectory . '/series';
+            if(!is_dir($series_directory)) {
+                return Command::SUCCESS;
+            }
+
+            $files = glob($series_directory . '/series-delete-*.json');
+
+            // We lock the files we are working on
+            // so that we don't process them again if the command is run again
+            $filteredFiles = [];
+            foreach ($files as $filepath) {
+                $lockFile = $filepath . '.lock';
+                if (!file_exists($lockFile)) {
+                    touch($lockFile);
+                    $filteredFiles[] = $filepath;
+                }
+            }
+
+            foreach ($filteredFiles as $filepath) {
+                $data = json_decode(file_get_contents($filepath), true, 512, 
JSON_THROW_ON_ERROR);
+                extract($data);
+                $this->deleteSeriesFiles($php_version, $vs_version);
+                unlink($filepath);
+                unlink($filepath . '.lock');
+            }
+            return Command::SUCCESS;
+        } catch (Exception $e) {
+            echo $e->getMessage();
+            return Command::FAILURE;
+        }
+    }
+
+    /**
+     * @throws Exception
+     */
+    private function deleteSeriesFiles(
+        string $php_version,
+        string $vs_version,
+    ): void
+    {
+        $baseDirectory = $this->baseDirectory . "/php-sdk/deps/series";
+
+        if (!is_dir($baseDirectory)) {
+            mkdir($baseDirectory, 0755, true);
+        }
+        foreach(['x86', 'x64'] as $arch) {
+            foreach(['stable', 'staging'] as $stability) {
+                $filePath = $baseDirectory . '/packages-' . $php_version . '-' 
. $vs_version . '-' . $arch . '-' . $stability . '.txt';
+                if(file_exists($filePath)) {
+                    unlink($filePath);
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Console/Command/SeriesStabilityCommand.php 
b/src/Console/Command/SeriesStabilityCommand.php
new file mode 100644
index 0000000..362cbca
--- /dev/null
+++ b/src/Console/Command/SeriesStabilityCommand.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Console\Command;
+
+use App\Console\Command;
+use Exception;
+
+class SeriesStabilityCommand extends Command
+{
+    protected string $signature = 'series:stability --base-directory= 
--builds-directory=';
+    protected string $description = 'Promote staging series files to stable 
for libraries';
+
+    protected ?string $baseDirectory = null;
+
+    public function handle(): int
+    {
+        try {
+            $this->baseDirectory = $this->getOption('base-directory');
+            if (!$this->baseDirectory) {
+                throw new Exception('Base directory is required');
+            }
+
+            $buildsDirectory = $this->getOption('builds-directory');
+            if (!$buildsDirectory) {
+                throw new Exception('Build directory is required');
+            }
+
+            $series_directory = $buildsDirectory . '/series';
+            if(!is_dir($series_directory)) {
+                return Command::SUCCESS;
+            }
+
+            $files = glob($series_directory . '/series-stability-*.json');
+
+            // We lock the files we are working on
+            // so that we don't process them again if the command is run again
+            $filteredFiles = [];
+            foreach ($files as $filepath) {
+                $lockFile = $filepath . '.lock';
+                if (!file_exists($lockFile)) {
+                    touch($lockFile);
+                    $filteredFiles[] = $filepath;
+                }
+            }
+
+            foreach ($filteredFiles as $filepath) {
+                $data = json_decode(file_get_contents($filepath), true, 512, 
JSON_THROW_ON_ERROR);
+                extract($data);
+                $this->promoteSeriesFiles($php_version, $vs_version);
+                unlink($filepath);
+                unlink($filepath . '.lock');
+            }
+            return Command::SUCCESS;
+        } catch (Exception $e) {
+            echo $e->getMessage();
+            return Command::FAILURE;
+        }
+    }
+
+    /**
+     * @throws Exception
+     */
+    private function promoteSeriesFiles(
+        string $php_version,
+        string $vs_version,
+    ): void
+    {
+        $baseDirectory = $this->baseDirectory . "/php-sdk/deps/series";
+
+        if (!is_dir($baseDirectory)) {
+            mkdir($baseDirectory, 0755, true);
+        }
+        foreach(['x86', 'x64'] as $arch) {
+            $sourceFile = $baseDirectory . '/packages-' . $php_version . '-' . 
$vs_version . '-' . $arch . '-staging.txt';
+            $destinationFile = $baseDirectory . '/packages-' . $php_version . 
'-' . $vs_version . '-' . $arch . '-stable.txt';
+            if(!file_exists($sourceFile)) {
+                throw new Exception($sourceFile . ' does not exist');
+            }
+            copy($sourceFile, $destinationFile);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Http/Controllers/SeriesDeleteController.php 
b/src/Http/Controllers/SeriesDeleteController.php
new file mode 100644
index 0000000..0f54c0a
--- /dev/null
+++ b/src/Http/Controllers/SeriesDeleteController.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\BaseController;
+use App\Validator;
+
+class SeriesDeleteController extends BaseController
+{
+    protected function validate(array $data): bool
+    {
+        $validator = new Validator([
+            'php_version' => 'required|string:regex:/^(\d+\.\d+|master)$/',
+            'vs_version' => 'required|string|regex:/^v[c|s]\d{2}$/',
+        ]);
+
+        $validator->validate($data);
+
+        $valid = $validator->isValid();
+
+        if (!$valid) {
+            http_response_code(400);
+            echo 'Invalid request: ' . $validator;
+        }
+
+        return $valid;
+    }
+
+    protected function execute(array $data): void
+    {
+        extract($data);
+        $directory = getenv('BUILDS_DIRECTORY') . '/series';
+        $hash = hash('sha256', $series) . strtotime('now');
+        $file = $directory . '/series-delete-' . $hash . '.json';
+        file_put_contents($file, json_encode($data));
+    }
+}
\ No newline at end of file
diff --git a/src/Http/Controllers/SeriesStabilityController.php 
b/src/Http/Controllers/SeriesStabilityController.php
new file mode 100644
index 0000000..9b56840
--- /dev/null
+++ b/src/Http/Controllers/SeriesStabilityController.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\BaseController;
+use App\Validator;
+
+class SeriesStabilityController extends BaseController
+{
+    protected function validate(array $data): bool
+    {
+        $validator = new Validator([
+            'php_version' => 'required|string:regex:/^(\d+\.\d+|master)$/',
+            'vs_version' => 'required|string|regex:/^v[c|s]\d{2}$/',
+        ]);
+
+        $validator->validate($data);
+
+        $valid = $validator->isValid();
+
+        if (!$valid) {
+            http_response_code(400);
+            echo 'Invalid request: ' . $validator;
+        }
+
+        return $valid;
+    }
+
+    protected function execute(array $data): void
+    {
+        extract($data);
+        $directory = getenv('BUILDS_DIRECTORY') . '/series';
+        $hash = hash('sha256', $series) . strtotime('now');
+        $file = $directory . '/series-stability-' . $hash . '.json';
+        file_put_contents($file, json_encode($data));
+    }
+}
\ No newline at end of file
diff --git a/tests/Console/Command/SeriesDeleteCommandTest.php 
b/tests/Console/Command/SeriesDeleteCommandTest.php
new file mode 100644
index 0000000..fbca7f2
--- /dev/null
+++ b/tests/Console/Command/SeriesDeleteCommandTest.php
@@ -0,0 +1,230 @@
+<?php
+
+namespace Console\Command;
+
+use App\Console\Command\SeriesDeleteCommand;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class SeriesDeleteCommandTest extends TestCase
+{
+    private string $baseDirectory;
+    private string $buildsDirectory;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->baseDirectory   = sys_get_temp_dir() . '/series_delete_base_' . 
uniqid();
+        $this->buildsDirectory = sys_get_temp_dir() . '/series_delete_builds_' 
. uniqid();
+
+        mkdir($this->baseDirectory, 0755, true);
+        mkdir($this->buildsDirectory, 0755, true);
+    }
+
+    protected function tearDown(): void
+    {
+        (new Helpers)->rmdirr($this->baseDirectory);
+        (new Helpers)->rmdirr($this->buildsDirectory);
+        parent::tearDown();
+    }
+
+    public static function versionProvider(): array
+    {
+        return [
+            ['8.3', 'vs17'],
+            ['8.2', 'vs16'],
+        ];
+    }
+
+    public function testReturnsSuccessWhenNoSeriesDir(): void
+    {
+        $command = new SeriesDeleteCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+
+        $this->assertSame(0, $result, 'Should return success when there is no 
builds/series directory.');
+    }
+
+    public function testMissingBaseDirectory(): void
+    {
+        $command = new SeriesDeleteCommand();
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = trim(ob_get_clean());
+
+        $this->assertSame(1, $result);
+        $this->assertSame('Base directory is required', $output);
+    }
+
+    public function testMissingBuildsDirectory(): void
+    {
+        $command = new SeriesDeleteCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = trim(ob_get_clean());
+
+        $this->assertSame(1, $result);
+        $this->assertSame('Build directory is required', $output);
+    }
+
+    #[DataProvider('versionProvider')]
+    public function testDeletesAllSeriesFilesAndCleansUpTask(string 
$phpVersion, string $vsVersion): void
+    {
+        $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesBase, 0755, true);
+
+        $paths = [
+            "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt",
+            "$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt",
+            "$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt",
+            "$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt",
+        ];
+        foreach ($paths as $p) {
+            file_put_contents($p, "dummy");
+        }
+
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+        $jsonPath = $seriesDir . '/series-delete-task1.json';
+        file_put_contents($jsonPath, json_encode([
+            'php_version' => $phpVersion,
+            'vs_version'  => $vsVersion,
+        ]));
+
+        clearstatcache(true);
+
+        $command = new SeriesDeleteCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+        $this->assertSame(0, $result, 'Command should return success.');
+
+        foreach ($paths as $p) {
+            $this->assertFileDoesNotExist($p);
+        }
+
+        $this->assertFileDoesNotExist($jsonPath);
+        $this->assertFileDoesNotExist($jsonPath . '.lock');
+    }
+
+    #[DataProvider('versionProvider')]
+    public function testIdempotentWhenSomeFilesMissing(string $phpVersion, 
string $vsVersion): void
+    {
+        $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesBase, 0755, true);
+
+        $existing = [
+            "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt",
+            "$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt",
+        ];
+        foreach ($existing as $p) {
+            file_put_contents($p, "exists");
+        }
+        $missing = [
+            "$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt",
+            "$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt",
+        ];
+
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+        $jsonPath = $seriesDir . '/series-delete-task2.json';
+        file_put_contents($jsonPath, json_encode([
+            'php_version' => $phpVersion,
+            'vs_version'  => $vsVersion,
+        ]));
+
+        clearstatcache(true);
+
+        $command = new SeriesDeleteCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+        $this->assertSame(0, $result);
+
+        foreach ($existing as $p) {
+            $this->assertFileDoesNotExist($p);
+        }
+        foreach ($missing as $p) {
+            $this->assertFileDoesNotExist($p);
+        }
+
+        $this->assertFileDoesNotExist($jsonPath);
+        $this->assertFileDoesNotExist($jsonPath . '.lock');
+    }
+
+    public function testSkipsLockedJsonFile(): void
+    {
+        $phpVersion = '8.3';
+        $vsVersion  = 'vs17';
+
+        $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesBase, 0755, true);
+
+        $target = "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt";
+        file_put_contents($target, 'keep');
+
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+        $jsonPath = $seriesDir . '/series-delete-locked.json';
+        file_put_contents($jsonPath, json_encode([
+            'php_version' => $phpVersion,
+            'vs_version'  => $vsVersion,
+        ]));
+        touch($jsonPath . '.lock');
+
+        clearstatcache(true);
+
+        $command = new SeriesDeleteCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+        $this->assertSame(0, $result);
+
+        $this->assertFileExists($jsonPath);
+        
+        $this->assertFileExists($target);
+    }
+
+    public function testHandlesCorruptJson(): void
+    {
+        $phpVersion = '8.3';
+        $vsVersion  = 'vs17';
+
+        $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesBase, 0755, true);
+
+        $target = "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt";
+        file_put_contents($target, 'data');
+
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+        $jsonPath = $seriesDir . '/series-delete-bad.json';
+        file_put_contents($jsonPath, '{corrupt json');
+
+        $command = new SeriesDeleteCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = ob_get_clean();
+
+        $this->assertSame(1, $result, 'Corrupt JSON should return FAILURE.');
+        $this->assertStringContainsString('Syntax error', $output);
+
+        $this->assertFileExists($jsonPath);
+        $this->assertFileExists($jsonPath . '.lock');
+
+        $this->assertFileExists($target);
+    }
+}
diff --git a/tests/Console/Command/SeriesStabilityCommandTest.php 
b/tests/Console/Command/SeriesStabilityCommandTest.php
new file mode 100644
index 0000000..cf186a6
--- /dev/null
+++ b/tests/Console/Command/SeriesStabilityCommandTest.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Console\Command;
+
+use App\Console\Command\SeriesStabilityCommand;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class SeriesStabilityCommandTest extends TestCase
+{
+    private string $baseDirectory;
+    private string $buildsDirectory;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->baseDirectory   = sys_get_temp_dir() . 
'/series_stability_base_' . uniqid();
+        $this->buildsDirectory = sys_get_temp_dir() . 
'/series_stability_builds_' . uniqid();
+
+        mkdir($this->baseDirectory, 0755, true);
+        mkdir($this->buildsDirectory, 0755, true);
+    }
+
+    protected function tearDown(): void
+    {
+        (new Helpers)->rmdirr($this->baseDirectory);
+        (new Helpers)->rmdirr($this->buildsDirectory);
+        parent::tearDown();
+    }
+
+    public static function versionProvider(): array
+    {
+        return [
+            ['8.3', 'vs17'],
+            ['8.2', 'vs16'],
+        ];
+    }
+
+    public function testReturnsSuccessWhenNoSeriesDir(): void
+    {
+        $command = new SeriesStabilityCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+
+        $this->assertSame(0, $result);
+    }
+
+    public function testMissingBaseDirectory(): void
+    {
+        $command = new SeriesStabilityCommand();
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = trim(ob_get_clean());
+
+        $this->assertSame(1, $result);
+        $this->assertSame('Base directory is required', $output);
+    }
+
+    public function testMissingBuildsDirectory(): void
+    {
+        $command = new SeriesStabilityCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = trim(ob_get_clean());
+
+        $this->assertSame(1, $result);
+        $this->assertSame('Build directory is required', $output);
+    }
+
+    #[DataProvider('versionProvider')]
+    public function testPromotesStagingToStableAndCleansTask(string 
$phpVersion, string $vsVersion): void
+    {
+        $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesBase, 0755, true);
+
+        $srcX86 = 
"$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt";
+        $srcX64 = 
"$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt";
+        file_put_contents($srcX86, "x86-content");
+        file_put_contents($srcX64, "x64-content");
+
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+        $jsonPath = $seriesDir . '/series-stability-task1.json';
+        file_put_contents($jsonPath, json_encode([
+            'php_version' => $phpVersion,
+            'vs_version'  => $vsVersion,
+        ]));
+
+        $command = new SeriesStabilityCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+        $this->assertSame(0, $result);
+
+        $dstX86 = "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt";
+        $dstX64 = "$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt";
+        $this->assertFileExists($dstX86);
+        $this->assertFileExists($dstX64);
+        $this->assertSame("x86-content", file_get_contents($dstX86));
+        $this->assertSame("x64-content", file_get_contents($dstX64));
+
+        $this->assertFileDoesNotExist($jsonPath);
+        $this->assertFileDoesNotExist($jsonPath . '.lock');
+    }
+
+    public function testSkipsLockedJsonFile(): void
+    {
+        $phpVersion = '8.3';
+        $vsVersion  = 'vs17';
+
+        $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesBase, 0755, true);
+        
file_put_contents("$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt",
 "x86");
+        
file_put_contents("$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt",
 "x64");
+
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+        $jsonPath = $seriesDir . '/series-stability-locked.json';
+        file_put_contents($jsonPath, json_encode([
+            'php_version' => $phpVersion,
+            'vs_version'  => $vsVersion,
+        ]));
+        touch($jsonPath . '.lock');
+
+        $command = new SeriesStabilityCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+        $this->assertSame(0, $result);
+
+        $this->assertFileExists($jsonPath);
+        $this->assertFileExists($jsonPath . '.lock');
+        
$this->assertFileDoesNotExist("$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt");
+        
$this->assertFileDoesNotExist("$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt");
+    }
+
+    public function testHandlesCorruptJson(): void
+    {
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+        $jsonPath = $seriesDir . '/series-stability-bad.json';
+        file_put_contents($jsonPath, '{corrupt json');
+
+        $command = new SeriesStabilityCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = ob_get_clean();
+
+        $this->assertSame(1, $result);
+        $this->assertStringContainsString('Syntax error', $output);
+        $this->assertFileExists($jsonPath);
+        $this->assertFileExists($jsonPath . '.lock');
+    }
+
+    public function testFailsWhenAnySourceStagingFileIsMissing(): void
+    {
+        $phpVersion = '8.3';
+        $vsVersion  = 'vs17';
+
+        $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesBase, 0755, true);
+        
file_put_contents("$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt",
 "x86");
+
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+        $jsonPath = $seriesDir . '/series-stability-task.json';
+        file_put_contents($jsonPath, json_encode([
+            'php_version' => $phpVersion,
+            'vs_version'  => $vsVersion,
+        ]));
+
+        $command = new SeriesStabilityCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = trim(ob_get_clean());
+
+        $this->assertSame(1, $result);
+        
$this->assertStringContainsString("$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt
 does not exist", $output);
+
+        $this->assertFileExists($jsonPath);
+        $this->assertFileExists($jsonPath . '.lock');
+        
$this->assertFileExists("$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt");
+        
$this->assertFileExists("$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt");
+        
$this->assertFileDoesNotExist("$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt");
+    }
+}
diff --git a/tests/Http/Controllers/SeriesDeleteControllerTest.php 
b/tests/Http/Controllers/SeriesDeleteControllerTest.php
new file mode 100644
index 0000000..23fe8cb
--- /dev/null
+++ b/tests/Http/Controllers/SeriesDeleteControllerTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Http\Controllers;
+
+use App\Http\Controllers\SeriesDeleteController;
+use PHPUnit\Framework\TestCase;
+
+class MockSeriesDeleteController extends SeriesDeleteController {
+    protected function validate(array $data): bool {
+        return isset($data['key']);
+    }
+
+    protected function execute(array $data): void {
+        echo "Executed";
+    }
+
+    public function handle(): void
+    {
+        $data = json_decode(file_get_contents($this->inputPath), true);
+
+        if ($this->validate($data)) {
+            $this->execute($data);
+        }
+    }
+}
+
+class SeriesDeleteControllerTest extends TestCase {
+    public function testHandleWithValidData() {
+        $data = json_encode(["key" => "value"]);
+        $tempFile = tempnam(sys_get_temp_dir(), 'phpunit');
+        file_put_contents($tempFile, $data);
+        $controller = new MockSeriesDeleteController($tempFile);
+        $this->expectOutputString("Executed");
+        $controller->handle();
+        unlink($tempFile);
+    }
+}
diff --git a/tests/Http/Controllers/SeriesStabilityControllerTest.php 
b/tests/Http/Controllers/SeriesStabilityControllerTest.php
new file mode 100644
index 0000000..45e69ca
--- /dev/null
+++ b/tests/Http/Controllers/SeriesStabilityControllerTest.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Http\Controllers;
+
+use App\Http\Controllers\SeriesInitController;
+use App\Http\Controllers\SeriesStabilityController;
+use PHPUnit\Framework\TestCase;
+
+class MockSeriesStabilityController extends SeriesStabilityController {
+    protected function validate(array $data): bool {
+        return isset($data['key']);
+    }
+
+    protected function execute(array $data): void {
+        echo "Executed";
+    }
+
+    public function handle(): void
+    {
+        $data = json_decode(file_get_contents($this->inputPath), true);
+
+        if ($this->validate($data)) {
+            $this->execute($data);
+        }
+    }
+}
+
+class SeriesStabilityControllerTest extends TestCase {
+    public function testHandleWithValidData() {
+        $data = json_encode(["key" => "value"]);
+        $tempFile = tempnam(sys_get_temp_dir(), 'phpunit');
+        file_put_contents($tempFile, $data);
+        $controller = new MockSeriesStabilityController($tempFile);
+        $this->expectOutputString("Executed");
+        $controller->handle();
+        unlink($tempFile);
+    }
+}

Reply via email to