1101 lines
33 KiB
Markdown
1101 lines
33 KiB
Markdown
# VikonPartialUpdates Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Implement partial module updates (per-part: common, struct, document...) in VikonIntegration with full backend support and Vue UI.
|
|
|
|
**Architecture:** Backend-driven polling — frontend sends one request, backend executes the 4-step flow (request → poll → check → download), returns result. Atomic swap with `_new`/`_old` suffixes for crash safety. Separate Action for orchestration, Task for polling loop.
|
|
|
|
**Tech Stack:** Laravel 10, PHP 8.1+, Vue 3 Composition API, Inertia.js, PHPUnit
|
|
|
|
## Global Constraints
|
|
|
|
- Porto Architecture: Route → Controller → Action → Task
|
|
- All PHP code in `app/Containers/VikonIntegration/`
|
|
- Tests in `app/Containers/VikonIntegration/Tests/Unit/` and `Tests/Feature/`
|
|
- Docker constraint: PHP/composer commands via `docker exec ntspi-php`
|
|
- Code in English, comments in English
|
|
- No Options API in Vue — Composition API only
|
|
|
|
---
|
|
|
|
### Task 1: Add `atomicSwap()` to FilesystemTask
|
|
|
|
**Covers:** [S3], [S7]
|
|
|
|
**Files:**
|
|
- Modify: `app/Containers/VikonIntegration/Tasks/FilesystemTask.php`
|
|
- Test: `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php`
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing `isPathSafe()` method
|
|
- Produces: `atomicSwap(string $newEntryPath, string $currentEntryPath, string $baseDir, int $moduleId): bool`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Containers\VikonIntegration\Tasks\FilesystemTask;
|
|
use Tests\TestCase;
|
|
use Illuminate\Support\Facades\File;
|
|
|
|
class FilesystemTaskTest extends TestCase
|
|
{
|
|
private string $tempDir;
|
|
private FilesystemTask $fs;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->tempDir = sys_get_temp_dir() . '/vikon_test_' . uniqid();
|
|
File::makeDirectory($this->tempDir, 0755, true, true);
|
|
$this->fs = new FilesystemTask();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
File::deleteDirectory($this->tempDir);
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function test_atomic_swap_replaces_existing_entry(): void
|
|
{
|
|
$base = $this->tempDir . '/module';
|
|
File::makeDirectory($base, 0755, true, true);
|
|
|
|
// Current entry exists
|
|
file_put_contents($base . '/common/index.php', '<?php echo "old";');
|
|
|
|
// New entry from extracted ZIP
|
|
$newDir = $this->tempDir . '/extracted/common';
|
|
File::makeDirectory($newDir, 0755, true, true);
|
|
file_put_contents($newDir . '/index.php', '<?php echo "new";');
|
|
|
|
$result = $this->fs->atomicSwap(
|
|
$newDir,
|
|
$base . '/common',
|
|
$base,
|
|
1
|
|
);
|
|
|
|
$this->assertTrue($result);
|
|
$this->assertFileExists($base . '/common/index.php');
|
|
$this->assertStringContainsString('new', file_get_contents($base . '/common/index.php'));
|
|
$this->assertDirectoryExists($base . '/common_old');
|
|
$this->assertDirectoryDoesNotExist($base . '/common_new');
|
|
}
|
|
|
|
public function test_atomic_swap_creates_new_entry_when_not_exists(): void
|
|
{
|
|
$base = $this->tempDir . '/module';
|
|
File::makeDirectory($base, 0755, true, true);
|
|
|
|
$newDir = $this->tempDir . '/extracted/assets';
|
|
File::makeDirectory($newDir, 0755, true, true);
|
|
file_put_contents($newDir . '/style.css', 'body {}');
|
|
|
|
$result = $this->fs->atomicSwap(
|
|
$newDir,
|
|
$base . '/assets',
|
|
$base,
|
|
1
|
|
);
|
|
|
|
$this->assertTrue($result);
|
|
$this->assertFileExists($base . '/assets/style.css');
|
|
$this->assertDirectoryDoesNotExist($base . '/assets_old');
|
|
}
|
|
|
|
public function test_atomic_swap_returns_false_on_path_traversal(): void
|
|
{
|
|
$result = $this->fs->atomicSwap(
|
|
'/etc/passwd',
|
|
$this->tempDir . '/module/etc',
|
|
$this->tempDir . '/module',
|
|
1
|
|
);
|
|
|
|
$this->assertFalse($result);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest`
|
|
Expected: FAIL with "Call to undefined method atomicSwap"
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
Add to `app/Containers/VikonIntegration/Tasks/FilesystemTask.php`:
|
|
|
|
```php
|
|
public function atomicSwap(
|
|
string $newEntryPath,
|
|
string $currentEntryPath,
|
|
string $baseDir,
|
|
int $moduleId
|
|
): bool {
|
|
if (!$this->isPathSafe($newEntryPath, $baseDir) || !$this->isPathSafe($currentEntryPath, $baseDir)) {
|
|
return false;
|
|
}
|
|
|
|
if (!File::exists($newEntryPath)) {
|
|
return false;
|
|
}
|
|
|
|
$isFile = is_file($newEntryPath);
|
|
$newPostfix = $currentEntryPath . '_new';
|
|
$oldPostfix = $currentEntryPath . '_old';
|
|
|
|
if (File::exists($currentEntryPath)) {
|
|
// Step 1: Remove stale _new if exists
|
|
if (File::exists($newPostfix)) {
|
|
$isFile ? File::delete($newPostfix) : File::deleteDirectory($newPostfix);
|
|
}
|
|
|
|
// Step 2: Move new → _new
|
|
if (!$this->moveEntry($newEntryPath, $newPostfix, $isFile)) {
|
|
return false;
|
|
}
|
|
|
|
// Step 3: Remove stale _old if exists
|
|
if (File::exists($oldPostfix)) {
|
|
$isFile ? File::delete($oldPostfix) : File::deleteDirectory($oldPostfix);
|
|
}
|
|
|
|
// Step 4: Move current → _old
|
|
if (!$this->moveEntry($currentEntryPath, $oldPostfix, $isFile)) {
|
|
return false;
|
|
}
|
|
|
|
// Step 5: Move _new → current
|
|
return $this->moveEntry($newPostfix, $currentEntryPath, $isFile);
|
|
}
|
|
|
|
// Entry doesn't exist — move directly
|
|
return $this->moveEntry($newEntryPath, $currentEntryPath, $isFile);
|
|
}
|
|
|
|
private function moveEntry(string $source, string $dest, bool $isFile): bool
|
|
{
|
|
if ($isFile) {
|
|
$parent = dirname($dest);
|
|
if (!File::isDirectory($parent)) {
|
|
File::makeDirectory($parent, 0755, true, true);
|
|
}
|
|
return rename($source, $dest);
|
|
}
|
|
return rename($source, $dest);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/Containers/VikonIntegration/Tasks/FilesystemTask.php app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php
|
|
git commit -m "feat(vikon): add atomicSwap to FilesystemTask for crash-safe updates"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create PollPartStatusTask
|
|
|
|
**Covers:** [S3], [S8]
|
|
|
|
**Files:**
|
|
- Create: `app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php`
|
|
- Test: `app/Containers/VikonIntegration/Tests/Unit/PollPartStatusTaskTest.php`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `HttpTask::getWithToken()`
|
|
- Produces: `run(string $operationIdentity, string $accessToken): array` returning `['status' => 'completed'|'failed'|'timeout']`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Containers\VikonIntegration\Tasks\PollPartStatusTask;
|
|
use App\Containers\VikonIntegration\Tasks\HttpTask;
|
|
use Tests\TestCase;
|
|
use Mockery;
|
|
|
|
class PollPartStatusTaskTest extends TestCase
|
|
{
|
|
protected function tearDown(): void
|
|
{
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function test_returns_completed_when_status_is_completed(): void
|
|
{
|
|
$http = Mockery::mock(HttpTask::class);
|
|
$response = Mockery::mock();
|
|
$response->shouldReceive('json')->once()->andReturn(['status' => 'completed']);
|
|
|
|
$http->shouldReceive('getWithToken')
|
|
->once()
|
|
->with(
|
|
Mockery::on(fn($endpoint) => str_contains($endpoint, 'getStatusPartGeneration')),
|
|
'test-token',
|
|
'api'
|
|
)
|
|
->andReturn($response);
|
|
|
|
$task = new PollPartStatusTask($http, 3, 50);
|
|
$result = $task->run('op-123', 'test-token');
|
|
|
|
$this->assertEquals('completed', $result['status']);
|
|
}
|
|
|
|
public function test_returns_failed_when_status_is_failed(): void
|
|
{
|
|
$http = Mockery::mock(HttpTask::class);
|
|
$response = Mockery::mock();
|
|
$response->shouldReceive('json')->once()->andReturn([
|
|
'status' => 'failed',
|
|
'message' => 'Generation error',
|
|
]);
|
|
|
|
$http->shouldReceive('getWithToken')
|
|
->once()
|
|
->andReturn($response);
|
|
|
|
$task = new PollPartStatusTask($http, 3, 50);
|
|
$result = $task->run('op-123', 'test-token');
|
|
|
|
$this->assertEquals('failed', $result['status']);
|
|
$this->assertEquals('Generation error', $result['error'] ?? null);
|
|
}
|
|
|
|
public function test_returns_timeout_after_max_attempts(): void
|
|
{
|
|
$http = Mockery::mock(HttpTask::class);
|
|
$response = Mockery::mock();
|
|
$response->shouldReceive('json')->andReturn(['status' => 'pending']);
|
|
|
|
$http->shouldReceive('getWithToken')
|
|
->times(3)
|
|
->andReturn($response);
|
|
|
|
$task = new PollPartStatusTask($http, 0, 3); // interval=0, max=3
|
|
$result = $task->run('op-123', 'test-token');
|
|
|
|
$this->assertEquals('timeout', $result['status']);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=PollPartStatusTaskTest`
|
|
Expected: FAIL with "Class not found"
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
Create `app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Containers\VikonIntegration\Tasks;
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class PollPartStatusTask
|
|
{
|
|
public function __construct(
|
|
private readonly HttpTask $http,
|
|
private readonly int $interval,
|
|
private readonly int $maxAttempts,
|
|
) {}
|
|
|
|
public function run(string $operationIdentity, string $accessToken): array
|
|
{
|
|
for ($attempt = 0; $attempt < $this->maxAttempts; $attempt++) {
|
|
$response = $this->http->getWithToken(
|
|
"pull_updates/getStatusPartGenerationByNewCoreJson?operation_identity={$operationIdentity}",
|
|
$accessToken
|
|
);
|
|
|
|
$body = $response->json();
|
|
$status = $body['status'] ?? 'unknown';
|
|
|
|
Log::info('Vikon poll part status', [
|
|
'operation' => $operationIdentity,
|
|
'status' => $status,
|
|
'attempt' => $attempt + 1,
|
|
]);
|
|
|
|
if ($status === 'completed') {
|
|
return ['status' => 'completed'];
|
|
}
|
|
|
|
if ($status === 'failed') {
|
|
return [
|
|
'status' => 'failed',
|
|
'error' => $body['message'] ?? 'Unknown error',
|
|
];
|
|
}
|
|
|
|
if ($attempt < $this->maxAttempts - 1) {
|
|
sleep($this->interval);
|
|
}
|
|
}
|
|
|
|
return ['status' => 'timeout'];
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=PollPartStatusTaskTest`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/Containers/VikonIntegration/Tasks/PollPartStatusTask.php app/Containers/VikonIntegration/Tests/Unit/PollPartStatusTaskTest.php
|
|
git commit -m "feat(vikon): add PollPartStatusTask for part update polling"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add parts config to vikon.php
|
|
|
|
**Covers:** [S8]
|
|
|
|
**Files:**
|
|
- Modify: `config/vikon.php`
|
|
- Modify: `app/Containers/VikonIntegration/Providers/VikonServiceProvider.php` (if needed for new config)
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing config structure
|
|
- Produces: `config('vikon.parts')`, `config('vikon.poll_interval')`, `config('vikon.poll_max_attempts')`
|
|
|
|
- [ ] **Step 1: Read current config**
|
|
|
|
Read `config/vikon.php` to see current structure.
|
|
|
|
- [ ] **Step 2: Add parts configuration**
|
|
|
|
Add to `config/vikon.php` before the closing `];`:
|
|
|
|
```php
|
|
'parts' => [
|
|
1 => ['common', 'struct', 'document', 'education', 'managers', 'employees', 'objects', 'paid_edu', 'budget', 'vacant', 'grants', 'inter', 'catering', 'eduStandarts', 'corruption', 'antiterrorism'],
|
|
2 => ['abitur'],
|
|
6 => ['general', 'structure', 'faq', 'procedures', 'results-and-reports', 'plans', 'survey'],
|
|
],
|
|
'poll_interval' => (int) env('VIKON_POLL_INTERVAL', 3),
|
|
'poll_max_attempts' => (int) env('VIKON_POLL_MAX_ATTEMPTS', 50),
|
|
```
|
|
|
|
- [ ] **Step 3: Verify config loads**
|
|
|
|
Run: `docker exec ntspi-php php artisan tinker --execute="dump(config('vikon.parts'));"`
|
|
Expected: Array with module IDs as keys
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add config/vikon.php
|
|
git commit -m "feat(vikon): add parts config for partial updates"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Create UpdatePartAction
|
|
|
|
**Covers:** [S3], [S5], [S6], [S7]
|
|
|
|
**Files:**
|
|
- Create: `app/Containers/VikonIntegration/Actions/UpdatePartAction.php`
|
|
- Test: `app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `HttpTask`, `FilesystemTask::atomicSwap()`, `PollPartStatusTask`
|
|
- Produces: `run(int $moduleId, string $part, string $accessToken): array`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Containers\VikonIntegration\Actions\UpdatePartAction;
|
|
use App\Containers\VikonIntegration\Tasks\HttpTask;
|
|
use App\Containers\VikonIntegration\Tasks\FilesystemTask;
|
|
use App\Containers\VikonIntegration\Tasks\PollPartStatusTask;
|
|
use Tests\TestCase;
|
|
use Mockery;
|
|
use Illuminate\Support\Facades\File;
|
|
|
|
class UpdatePartActionTest extends TestCase
|
|
{
|
|
private string $tempDir;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->tempDir = sys_get_temp_dir() . '/vikon_update_test_' . uniqid();
|
|
File::makeDirectory($this->tempDir, 0755, true, true);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
File::deleteDirectory($this->tempDir);
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function test_rejects_invalid_module_id(): void
|
|
{
|
|
$http = Mockery::mock(HttpTask::class);
|
|
$fs = Mockery::mock(FilesystemTask::class);
|
|
$poll = Mockery::mock(PollPartStatusTask::class);
|
|
|
|
$action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [
|
|
1 => ['path' => 'sveden', 'allowed_folders' => ['common']],
|
|
]);
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
$action->run(999, 'common', 'token');
|
|
}
|
|
|
|
public function test_rejects_invalid_part(): void
|
|
{
|
|
$http = Mockery::mock(HttpTask::class);
|
|
$fs = Mockery::mock(FilesystemTask::class);
|
|
$poll = Mockery::mock(PollPartStatusTask::class);
|
|
|
|
$action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [
|
|
1 => ['path' => 'sveden', 'allowed_folders' => ['common']],
|
|
]);
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
$action->run(1, 'nonexistent', 'token');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=UpdatePartActionTest`
|
|
Expected: FAIL with "Class not found"
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
Create `app/Containers/VikonIntegration/Actions/UpdatePartAction.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Containers\VikonIntegration\Actions;
|
|
|
|
use App\Containers\VikonIntegration\Tasks\HttpTask;
|
|
use App\Containers\VikonIntegration\Tasks\FilesystemTask;
|
|
use App\Containers\VikonIntegration\Tasks\PollPartStatusTask;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
use ZipArchive;
|
|
|
|
class UpdatePartAction
|
|
{
|
|
public function __construct(
|
|
private readonly HttpTask $http,
|
|
private readonly FilesystemTask $fs,
|
|
private readonly PollPartStatusTask $pollStatus,
|
|
private readonly string $storagePath,
|
|
private readonly string $basePath,
|
|
private readonly array $modulesConfig,
|
|
) {}
|
|
|
|
public function run(int $moduleId, string $part, string $accessToken): array
|
|
{
|
|
$config = $this->modulesConfig[$moduleId] ?? throw new \RuntimeException("Unknown module: {$moduleId}");
|
|
|
|
if (!in_array($part, config('vikon.parts', [])[$moduleId] ?? [], true)) {
|
|
throw new \RuntimeException("Invalid part '{$part}' for module {$moduleId}");
|
|
}
|
|
|
|
Log::info('Vikon: starting part update', ['module' => $moduleId, 'part' => $part]);
|
|
|
|
// Step 1: Request generation
|
|
$genResponse = $this->http->postWithToken(
|
|
'pull_updates/requestGeneratePartByNewCoreJson',
|
|
$accessToken,
|
|
['part' => $part]
|
|
);
|
|
$genBody = $genResponse->json();
|
|
|
|
if (empty($genBody['operation_identity'])) {
|
|
throw new \RuntimeException('Failed to request part generation: ' . ($genBody['message'] ?? 'Unknown'));
|
|
}
|
|
|
|
$operationIdentity = $genBody['operation_identity'];
|
|
$ttl = $genBody['ttl'] ?? 120;
|
|
|
|
Log::info('Vikon: part generation requested', ['operation' => $operationIdentity, 'ttl' => $ttl]);
|
|
|
|
// Step 2: Poll status
|
|
$pollResult = $this->pollStatus->run($operationIdentity, $accessToken);
|
|
|
|
if ($pollResult['status'] !== 'completed') {
|
|
$error = $pollResult['error'] ?? $pollResult['status'];
|
|
throw new \RuntimeException("Part generation failed: {$error}");
|
|
}
|
|
|
|
// Step 3: Check result
|
|
$checkResponse = $this->http->postWithToken(
|
|
'pull_updates/checkPartGenerationByNewCoreResultJson',
|
|
$accessToken,
|
|
['operation_identity' => $operationIdentity, 'part' => $part]
|
|
);
|
|
$checkBody = $checkResponse->json();
|
|
|
|
if (empty($checkBody['success'])) {
|
|
throw new \RuntimeException('Part not ready: ' . ($checkBody['message'] ?? 'Unknown'));
|
|
}
|
|
|
|
// Step 4: Download and apply
|
|
$zipContent = $this->http->downloadWithToken(
|
|
"pull_updates/downloadPartByNewCoreResult?operation_identity={$operationIdentity}&part={$part}",
|
|
$accessToken
|
|
);
|
|
|
|
$tempPath = $this->storagePath . '/temp/' . $config['path'] . '_part';
|
|
File::makeDirectory($tempPath, 0755, true, true);
|
|
|
|
$zipFile = $tempPath . '/part.zip';
|
|
file_put_contents($zipFile, $zipContent);
|
|
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($zipFile) !== true) {
|
|
throw new \RuntimeException('Failed to open part ZIP');
|
|
}
|
|
$zip->extractTo($tempPath);
|
|
$zip->close();
|
|
File::delete($zipFile);
|
|
|
|
$modulePath = $this->basePath . '/' . $config['path'];
|
|
$moduleConfig = $this->modulesConfig[$moduleId];
|
|
|
|
// Determine which module owns this part
|
|
$ownerModuleId = $moduleId;
|
|
if ($part !== 'abitur') {
|
|
// For non-ABITUR parts, apply to the module that owns them
|
|
$ownerModuleId = $moduleId;
|
|
}
|
|
|
|
$syncedCount = $this->applyPart($part, $tempPath, $modulePath, $ownerModuleId, $moduleConfig);
|
|
|
|
// Clean temp
|
|
File::deleteDirectory($tempPath);
|
|
|
|
Log::info('Vikon: part update complete', ['module' => $moduleId, 'part' => $part, 'synced' => $syncedCount]);
|
|
|
|
return [
|
|
'success' => true,
|
|
"message" => "Part '{$part}' updated successfully.",
|
|
'synced_count' => $syncedCount,
|
|
];
|
|
}
|
|
|
|
private function applyPart(
|
|
string $part,
|
|
string $tempPath,
|
|
string $modulePath,
|
|
int $moduleId,
|
|
array $moduleConfig
|
|
): int {
|
|
if ($part === 'abitur') {
|
|
return $this->applyAbiturPart($tempPath, $modulePath, $moduleId, $moduleConfig);
|
|
}
|
|
|
|
return $this->applyRegularPart($part, $tempPath, $modulePath, $moduleId, $moduleConfig);
|
|
}
|
|
|
|
private function applyRegularPart(
|
|
string $part,
|
|
string $tempPath,
|
|
string $modulePath,
|
|
int $moduleId,
|
|
array $moduleConfig
|
|
): int {
|
|
$partSource = $tempPath . '/' . $part;
|
|
$partTarget = $modulePath . '/' . $part;
|
|
|
|
if (!File::exists($partSource)) {
|
|
// ZIP might have the part nested under a directory
|
|
$extractedDirs = File::directories($tempPath);
|
|
if (!empty($extractedDirs)) {
|
|
$partSource = $extractedDirs[0] . '/' . $part;
|
|
}
|
|
}
|
|
|
|
if (!File::exists($partSource)) {
|
|
throw new \RuntimeException("Part directory not found in ZIP: {$part}");
|
|
}
|
|
|
|
$result = $this->fs->atomicSwap($partSource, $partTarget, $modulePath, $moduleId);
|
|
if (!$result) {
|
|
$this->fs->restoreAfterFail($modulePath, [$part], $moduleId);
|
|
throw new \RuntimeException("Failed to apply part: {$part}");
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
private function applyAbiturPart(
|
|
string $tempPath,
|
|
string $modulePath,
|
|
int $moduleId,
|
|
array $moduleConfig
|
|
): int {
|
|
$abiturSource = $tempPath . '/abitur';
|
|
if (!File::exists($abiturSource)) {
|
|
$extractedDirs = File::directories($tempPath);
|
|
if (!empty($extractedDirs)) {
|
|
$abiturSource = $extractedDirs[0] . '/abitur';
|
|
}
|
|
}
|
|
|
|
if (!File::exists($abiturSource)) {
|
|
throw new \RuntimeException('ABITUR directory not found in ZIP');
|
|
}
|
|
|
|
$entries = File::allFiles($abiturSource);
|
|
$synced = 0;
|
|
|
|
foreach ($entries as $file) {
|
|
$relative = ltrim(str_replace($abiturSource, '', $file->getPathname()), '/');
|
|
$targetPath = $modulePath . '/' . $relative;
|
|
$targetDir = dirname($targetPath);
|
|
|
|
if (!File::isDirectory($targetDir)) {
|
|
File::makeDirectory($targetDir, 0755, true, true);
|
|
}
|
|
|
|
$result = $this->fs->atomicSwap(
|
|
$file->getPathname(),
|
|
$targetPath,
|
|
$modulePath,
|
|
$moduleId
|
|
);
|
|
|
|
if (!$result) {
|
|
$this->fs->restoreAfterFail($modulePath, ['abitur'], $moduleId);
|
|
throw new \RuntimeException("Failed to sync ABITUR file: {$relative}");
|
|
}
|
|
|
|
$synced++;
|
|
}
|
|
|
|
return $synced;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=UpdatePartActionTest`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/Containers/VikonIntegration/Actions/UpdatePartAction.php app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php
|
|
git commit -m "feat(vikon): add UpdatePartAction for partial module updates"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add `restoreAfterFail()` to FilesystemTask
|
|
|
|
**Covers:** [S7]
|
|
|
|
**Files:**
|
|
- Modify: `app/Containers/VikonIntegration/Tasks/FilesystemTask.php`
|
|
- Test: `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php` (add test)
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing `isPathSafe()`, `atomicSwap()`
|
|
- Produces: `restoreAfterFail(string $modulePath, array $allowedEntries, int $moduleId): bool`
|
|
|
|
- [ ] **Step 1: Add failing test**
|
|
|
|
Append to `app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php`:
|
|
|
|
```php
|
|
public function test_restore_after_fail_removes_new_and_restores_old(): void
|
|
{
|
|
$base = $this->tempDir . '/module';
|
|
File::makeDirectory($base, 0755, true, true);
|
|
|
|
// Simulate partial swap state: original exists, _old has backup
|
|
file_put_contents($base . '/common/index.php', '<?php echo "current";');
|
|
file_put_contents($base . '/common_old/index.php', '<?php echo "original";');
|
|
|
|
$result = $this->fs->restoreAfterFail($base, ['common'], 1);
|
|
|
|
$this->assertTrue($result);
|
|
$this->assertFileExists($base . '/common/index.php');
|
|
$this->assertStringContainsString('original', file_get_contents($base . '/common/index.php'));
|
|
$this->assertDirectoryDoesNotExist($base . '/common_old');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest::test_restore_after_fail`
|
|
Expected: FAIL with "Call to undefined method restoreAfterFail"
|
|
|
|
- [ ] **Step 3: Write implementation**
|
|
|
|
Add to `app/Containers/VikonIntegration/Tasks/FilesystemTask.php`:
|
|
|
|
```php
|
|
public function restoreAfterFail(string $modulePath, array $allowedEntries, int $moduleId): bool
|
|
{
|
|
$entries = File::directories($modulePath);
|
|
$entries = array_merge($entries, File::files($modulePath));
|
|
|
|
foreach ($entries as $entryPath) {
|
|
$name = basename($entryPath);
|
|
|
|
// Remove _new entries (they were never applied)
|
|
if (str_ends_with($name, '_new')) {
|
|
$baseName = substr($name, 0, -4);
|
|
if (in_array($baseName, $allowedEntries, true)) {
|
|
if (is_dir($entryPath)) {
|
|
File::deleteDirectory($entryPath);
|
|
} else {
|
|
File::delete($entryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore _old entries back to original name
|
|
if (str_ends_with($name, '_old')) {
|
|
$baseName = substr($name, 0, -4);
|
|
if (in_array($baseName, $allowedEntries, true)) {
|
|
$originalPath = $modulePath . '/' . $baseName;
|
|
if (File::exists($originalPath)) {
|
|
if (is_dir($originalPath)) {
|
|
File::deleteDirectory($originalPath);
|
|
} else {
|
|
File::delete($originalPath);
|
|
}
|
|
}
|
|
rename($entryPath, $originalPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=FilesystemTaskTest`
|
|
Expected: PASS (all 4 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/Containers/VikonIntegration/Tasks/FilesystemTask.php app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php
|
|
git commit -m "feat(vikon): add restoreAfterFail for crash recovery in FilesystemTask"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Create UpdatePartRequest and Controller route
|
|
|
|
**Covers:** [S5]
|
|
|
|
**Files:**
|
|
- Create: `app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php`
|
|
- Modify: `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php`
|
|
- Modify: `app/Containers/VikonIntegration/UI/WEB/Routes/web.php`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `UpdatePartAction::run()`
|
|
- Produces: `POST /dashboard/vikon-updates/update-part` endpoint
|
|
|
|
- [ ] **Step 1: Create UpdatePartRequest**
|
|
|
|
Create `app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Containers\VikonIntegration\UI\WEB\Requests;
|
|
|
|
use Illuminate\Foundation\Http\FormRequest;
|
|
|
|
class UpdatePartRequest extends FormRequest
|
|
{
|
|
public function authorize(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function rules(): array
|
|
{
|
|
return [
|
|
'module_id' => ['required', 'integer', 'in:1,2,6'],
|
|
'part' => ['required', 'string', 'max:50'],
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add updatePart method to VikonController**
|
|
|
|
Add to `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php`:
|
|
|
|
```php
|
|
use App\Containers\VikonIntegration\Actions\UpdatePartAction;
|
|
use App\Containers\VikonIntegration\UI\WEB\Requests\UpdatePartRequest;
|
|
```
|
|
|
|
Add property and constructor injection:
|
|
|
|
```php
|
|
private readonly UpdatePartAction $updatePart,
|
|
```
|
|
|
|
Add method:
|
|
|
|
```php
|
|
public function updatePart(UpdatePartRequest $request): JsonResponse
|
|
{
|
|
$token = Session::get('vikon_access_token');
|
|
if (!$token) {
|
|
return response()->json(['success' => false, 'requires_auth' => true], 401);
|
|
}
|
|
|
|
try {
|
|
$result = $this->updatePart->run(
|
|
$request->validated('module_id'),
|
|
$request->validated('part'),
|
|
$token
|
|
);
|
|
return response()->json($result);
|
|
} catch (\Throwable $e) {
|
|
Log::error('Vikon part update failed', ['error' => $e->getMessage()]);
|
|
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add route**
|
|
|
|
Add to `app/Containers/VikonIntegration/UI/WEB/Routes/web.php`:
|
|
|
|
```php
|
|
Route::post('/update-part', [VikonController::class, 'updatePart'])->name('update-part');
|
|
```
|
|
|
|
- [ ] **Step 4: Verify route exists**
|
|
|
|
Run: `docker exec ntspi-php php artisan route:list --name=dashboard.vikon-updates`
|
|
Expected: Shows `update-part` route
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/Containers/VikonIntegration/UI/WEB/Requests/UpdatePartRequest.php app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php app/Containers/VikonIntegration/UI/WEB/Routes/web.php
|
|
git commit -m "feat(vikon): add updatePart endpoint with request validation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Update Vue frontend with part selection UI
|
|
|
|
**Covers:** [S4]
|
|
|
|
**Files:**
|
|
- Modify: `resources/js/Pages/Dashboard/VikonUpdates/Index.vue`
|
|
- Modify: `app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php` (add parts to props)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `POST /dashboard/vikon-updates/update-part` endpoint
|
|
- Produces: UI with module/part selection and update button
|
|
|
|
- [ ] **Step 1: Add parts to controller props**
|
|
|
|
In `VikonController::index()` and `oauthCallback()`, add to the inertia render:
|
|
|
|
```php
|
|
'parts' => config('vikon.parts'),
|
|
```
|
|
|
|
- [ ] **Step 2: Update Index.vue**
|
|
|
|
Read current `resources/js/Pages/Dashboard/VikonUpdates/Index.vue` and add:
|
|
|
|
1. New prop: `parts`
|
|
2. New reactive state: `selectedPart`, `updatingPart`, `partResult`
|
|
3. New method: `updatePart(moduleId, part)`
|
|
4. UI section: dropdown for part selection per module, update button, progress indicator
|
|
|
|
Key additions to the `<script setup>`:
|
|
|
|
```javascript
|
|
const props = defineProps({
|
|
// ... existing props
|
|
parts: Object,
|
|
});
|
|
|
|
const selectedPart = ref({});
|
|
const updatingPart = ref(null);
|
|
const partResult = ref(null);
|
|
|
|
async function updatePart(moduleId, part) {
|
|
updatingPart.value = `${moduleId}-${part}`;
|
|
partResult.value = null;
|
|
|
|
try {
|
|
const res = await fetch('/dashboard/vikon-updates/update-part', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content,
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify({ module_id: moduleId, part }),
|
|
});
|
|
|
|
const data = await res.json();
|
|
partResult.value = data;
|
|
} catch (e) {
|
|
partResult.value = { success: false, message: e.message };
|
|
} finally {
|
|
updatingPart.value = null;
|
|
}
|
|
}
|
|
```
|
|
|
|
Template additions (inside the module list):
|
|
|
|
```html
|
|
<div v-if="parts && parts[moduleId]" class="mt-2">
|
|
<select v-model="selectedPart[moduleId]" class="text-sm border rounded px-2 py-1">
|
|
<option value="">Select part...</option>
|
|
<option v-for="p in parts[moduleId]" :key="p" :value="p">{{ p }}</option>
|
|
</select>
|
|
<button
|
|
@click="updatePart(moduleId, selectedPart[moduleId])"
|
|
:disabled="!selectedPart[moduleId] || updatingPart"
|
|
class="ml-2 text-sm bg-blue-500 text-white px-3 py-1 rounded disabled:opacity-50"
|
|
>
|
|
<span v-if="updatingPart === `${moduleId}-${selectedPart[moduleId]}`">Updating...</span>
|
|
<span v-else>Update Part</span>
|
|
</button>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 3: Verify Vue compiles**
|
|
|
|
Run: `npm run build`
|
|
Expected: No errors
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add resources/js/Pages/Dashboard/VikonUpdates/Index.vue app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php
|
|
git commit -m "feat(vikon): add part selection UI to Vue dashboard"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Integration test for full flow
|
|
|
|
**Covers:** [S3], [S5], [S7]
|
|
|
|
**Files:**
|
|
- Create: `app/Containers/VikonIntegration/Tests/Feature/UpdatePartTest.php`
|
|
|
|
**Interfaces:**
|
|
- Consumes: all previous tasks
|
|
- Produces: Feature test hitting the HTTP endpoint
|
|
|
|
- [ ] **Step 1: Create feature test**
|
|
|
|
Create `app/Containers/VikonIntegration/Tests/Feature/UpdatePartTest.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use Tests\TestCase;
|
|
use Illuminate\Support\Facades\Session;
|
|
|
|
class UpdatePartTest extends TestCase
|
|
{
|
|
public function test_update_part_requires_authentication(): void
|
|
{
|
|
$response = $this->postJson('/dashboard/vikon-updates/update-part', [
|
|
'module_id' => 1,
|
|
'part' => 'common',
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
public function test_update_part_validates_module_id(): void
|
|
{
|
|
Session::put('vikon_access_token', 'test-token');
|
|
|
|
$response = $this->postJson('/dashboard/vikon-updates/update-part', [
|
|
'module_id' => 999,
|
|
'part' => 'common',
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
public function test_update_part_validates_part(): void
|
|
{
|
|
Session::put('vikon_access_token', 'test-token');
|
|
|
|
$response = $this->postJson('/dashboard/vikon-updates/update-part', [
|
|
'module_id' => 1,
|
|
'part' => '',
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test**
|
|
|
|
Run: `docker exec ntspi-php php artisan test --filter=UpdatePartTest`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/Containers/VikonIntegration/Tests/Feature/UpdatePartTest.php
|
|
git commit -m "test(vikon): add feature tests for part update endpoint"
|
|
```
|