From da90011ad3af9e7c1e43a9a290606c06db22fed6 Mon Sep 17 00:00:00 2001 From: F4ilji Date: Sun, 5 Jul 2026 02:45:47 +0500 Subject: [PATCH] feat(vikon): add UpdatePartAction for partial module updates --- .../Actions/UpdatePartAction.php | 196 ++++++++++++++++++ .../Tests/Unit/UpdatePartActionTest.php | 179 ++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 app/Containers/VikonIntegration/Actions/UpdatePartAction.php create mode 100644 app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php diff --git a/app/Containers/VikonIntegration/Actions/UpdatePartAction.php b/app/Containers/VikonIntegration/Actions/UpdatePartAction.php new file mode 100644 index 0000000..c61124c --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/UpdatePartAction.php @@ -0,0 +1,196 @@ +modulesConfig[$moduleId] ?? throw new \RuntimeException("Unknown module: {$moduleId}"); + + $allowedParts = config('vikon.parts', [])[$moduleId] ?? []; + if (!in_array($part, $allowedParts, 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']; + + Log::info('Vikon: part generation requested', ['operation' => $operationIdentity]); + + // 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 ZIP + $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']; + + // Step 5: Apply + $syncedCount = $this->applyPart($part, $tempPath, $modulePath, $moduleId, $config); + + // Step 6: 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); + } + + return $this->applyRegularPart($part, $tempPath, $modulePath, $moduleId); + } + + private function applyRegularPart( + string $part, + string $tempPath, + string $modulePath, + int $moduleId + ): int { + $partSource = $tempPath . '/' . $part; + $partTarget = $modulePath . '/' . $part; + + if (!File::exists($partSource)) { + $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 + ): 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; + } +} diff --git a/app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php b/app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php new file mode 100644 index 0000000..d53d650 --- /dev/null +++ b/app/Containers/VikonIntegration/Tests/Unit/UpdatePartActionTest.php @@ -0,0 +1,179 @@ +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(); + } + + private function mockResponse(array $data): Response + { + $response = Mockery::mock(Response::class); + $response->shouldReceive('json')->once()->andReturn($data); + return $response; + } + + 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); + $this->expectExceptionMessage('Unknown module: 999'); + $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); + $this->expectExceptionMessage("Invalid part 'nonexistent' for module 1"); + $action->run(1, 'nonexistent', 'token'); + } + + public function test_requests_generation_and_polls_status(): void + { + $http = Mockery::mock(HttpTask::class); + $fs = Mockery::mock(FilesystemTask::class); + $poll = Mockery::mock(PollPartStatusTask::class); + + $http->shouldReceive('postWithToken') + ->once() + ->with('pull_updates/requestGeneratePartByNewCoreJson', 'token', ['part' => 'common']) + ->andReturn($this->mockResponse([ + 'operation_identity' => 'op-abc-123', + 'ttl' => 60, + ])); + + $poll->shouldReceive('run') + ->once() + ->with('op-abc-123', 'token') + ->andReturn(['status' => 'completed']); + + $http->shouldReceive('postWithToken') + ->once() + ->with('pull_updates/checkPartGenerationByNewCoreResultJson', 'token', [ + 'operation_identity' => 'op-abc-123', + 'part' => 'common', + ]) + ->andReturn($this->mockResponse(['success' => true])); + + // Create a minimal ZIP + $tempZipDir = $this->tempDir . '/zip_source'; + File::makeDirectory($tempZipDir . '/common', 0755, true, true); + file_put_contents($tempZipDir . '/common/index.html', 'new'); + + $zipPath = $this->tempDir . '/test_part.zip'; + $zip = new \ZipArchive(); + $zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFile($tempZipDir . '/common/index.html', 'common/index.html'); + $zip->close(); + + $zipContent = file_get_contents($zipPath); + + $http->shouldReceive('downloadWithToken') + ->once() + ->andReturn($zipContent); + + $fs->shouldReceive('atomicSwap') + ->once() + ->andReturn(true); + + config(['vikon.parts' => [1 => ['common']]]); + + $action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [ + 1 => ['path' => 'sveden', 'allowed_folders' => ['common']], + ]); + + $result = $action->run(1, 'common', 'token'); + + $this->assertTrue($result['success']); + $this->assertEquals("Part 'common' updated successfully.", $result['message']); + } + + public function test_throws_on_generation_failure(): void + { + $http = Mockery::mock(HttpTask::class); + $fs = Mockery::mock(FilesystemTask::class); + $poll = Mockery::mock(PollPartStatusTask::class); + + $http->shouldReceive('postWithToken') + ->once() + ->andReturn($this->mockResponse([ + 'message' => 'Generation not available', + ])); + + config(['vikon.parts' => [1 => ['common']]]); + + $action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [ + 1 => ['path' => 'sveden', 'allowed_folders' => ['common']], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to request part generation'); + $action->run(1, 'common', 'token'); + } + + public function test_throws_on_poll_failure(): void + { + $http = Mockery::mock(HttpTask::class); + $fs = Mockery::mock(FilesystemTask::class); + $poll = Mockery::mock(PollPartStatusTask::class); + + $http->shouldReceive('postWithToken') + ->once() + ->andReturn($this->mockResponse([ + 'operation_identity' => 'op-abc-123', + 'ttl' => 60, + ])); + + $poll->shouldReceive('run') + ->once() + ->andReturn(['status' => 'failed', 'error' => 'Server error']); + + config(['vikon.parts' => [1 => ['common']]]); + + $action = new UpdatePartAction($http, $fs, $poll, $this->tempDir, $this->tempDir, [ + 1 => ['path' => 'sveden', 'allowed_folders' => ['common']], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Part generation failed: Server error'); + $action->run(1, 'common', 'token'); + } +}