d2d.emegrendeles.hu/tests/Feature/DeploymentPackageTest.php
E98Developer 2965f231ce ADD Deployment csomagoló queue worker és scheduler újraindítás a teendők közé
A 2026-08-17-i eset általánosítása: a d2d/e2e Docker Swarmban ugyanazt a kódot több
service futtatja közös volume-ról, és a queue:work hosszan futó folyamat a betöltött
osztályokat a memóriájában tartja. A fájlmásolás után a worker RÉGI kóddal dolgozta fel
a jobot - a felület jónak látszott, a háttérfeladat némán a régi viselkedést hozta.

- config: 'runtime' minta (app/, config/, database/, routes/, bootstrap/, composer.lock),
  mert a restart nem csak a jobok változásakor kell; targetenként a service nevek és a
  Swarm manager elérése
- a felületen figyelmeztetés, a teendőkben a konkrét ssh + docker service update parancs
- a restart szándékosan a cache ürítés UTÁN: a frissen induló worker különben a régi,
  cache-elt konfigot töltené be
- a d2d schedulernél jelezzük, hogy 0 replikán fut (a restart üresjárat lehet), a t2t-nél
  pedig azt, hogy queue service még nincs, de a névkonvenció alapján t2t_queue lesz

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 06:41:19 +02:00

608 lines
23 KiB
PHP

<?php
use App\Filament\Pages\DeploymentPackage;
use App\Models\DeploymentPackage as DeploymentPackageRecord;
use App\Models\FeatureFlag;
use App\Models\Role;
use App\Models\User;
use App\Services\Deployment\ChangeSetAnalyzer;
use App\Services\Deployment\DeploymentGuard;
use App\Services\Deployment\DeploymentPackageBuilder;
use App\Services\Deployment\GitRepository;
use App\Services\FeatureFlagRegistrar;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Symfony\Component\Process\Process;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
// Az oldal betöltéskor lekérdezi a környezetek deploy-version.json-ját - a tesztek
// nem indíthatnak valódi kimenő kérést.
beforeEach(function () {
Http::fake();
});
/**
* Fejlesztői gépet szimuláló környezet: az .env kapcsoló és a stage whitelist rendben.
*/
function deploymentAllowEnvironment(): void
{
Config::set('deployment.enabled', true);
Config::set('app.stage', 'local');
}
function deploymentDeveloper(bool $flagEnabled = true): User
{
$role = Role::firstOrCreate(['name' => 'developer'], ['display_name' => 'Developer']);
$user = User::factory()->create();
$user->addRole($role);
FeatureFlag::create([
'name' => 'DeploymentPackage',
'label' => 'Deployment csomagoló',
'enabled' => $flagEnabled,
'stages' => null,
'roles' => ['developer'],
]);
app(FeatureFlagRegistrar::class)->registerAll();
return $user;
}
/**
* @param array<int, string> $arguments
*/
function deploymentGit(string $path, array $arguments): string
{
$process = new Process(array_merge(['git'], $arguments), $path);
$process->mustRun();
return $process->getOutput();
}
/**
* Determinisztikus teszt-repó: átnevezés, törlés, ékezetes fájlnév és migráció is van benne.
*
* @return array{path:string, commits:array<int, string>}
*/
function deploymentTestRepository(): array
{
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'deployment-package-test-'.uniqid();
File::makeDirectory($path.'/app', 0777, true);
File::makeDirectory($path.'/database/migrations', 0777, true);
File::makeDirectory($path.'/database/seeders', 0777, true);
deploymentGit($path, ['init']);
deploymentGit($path, ['config', 'user.email', 'teszt@example.com']);
deploymentGit($path, ['config', 'user.name', 'Teszt']);
deploymentGit($path, ['config', 'commit.gpgsign', 'false']);
File::put($path.'/app/Elso.php', "<?php\n// elso\n");
File::put($path.'/app/Regi.php', "<?php\n// regi\n");
File::put($path.'/app/Atnevezendo.php', "<?php\n// atnevezendo\n");
deploymentGit($path, ['add', '-A']);
deploymentGit($path, ['commit', '-m', 'első commit']);
deploymentGit($path, ['branch', '-M', 'main']);
File::put($path.'/app/Elso.php', "<?php\n// elso modositva\n");
File::put($path.'/app/Árlista.php', "<?php\n// ekezetes\n");
File::put($path.'/database/migrations/2026_01_01_000000_teszt.php', "<?php\n// migracio\n");
File::put($path.'/database/seeders/TesztSeeder.php', "<?php\n// seeder\n");
File::put($path.'/.env', "APP_KEY=titok\n");
deploymentGit($path, ['add', '-A', '-f']);
deploymentGit($path, ['commit', '-m', "második commit\n\nA törzsben említett árlista részlet."]);
deploymentGit($path, ['mv', 'app/Atnevezendo.php', 'app/Atnevezett.php']);
deploymentGit($path, ['rm', 'app/Regi.php']);
deploymentGit($path, ['commit', '-m', 'harmadik commit']);
$log = trim(deploymentGit($path, ['log', '--reverse', '--pretty=format:%H']));
Config::set('deployment.repo_path', $path);
Config::set('deployment.output_path', $path.DIRECTORY_SEPARATOR.'_kimenet');
return ['path' => $path, 'commits' => explode("\n", $log)];
}
function deploymentCleanup(string $path): void
{
// A .git/objects tartalma Windowson csak olvasható, ezért törlés előtt fel kell oldani.
foreach (File::allFiles($path, true) as $file) {
@chmod($file->getPathname(), 0777);
}
try {
File::deleteDirectory($path);
} catch (Throwable) {
// A takarítás nem futtathat el tesztet - a temp mappa maradványa nem befolyásol semmit.
}
}
test('a kikapcsolt env kapcsoló mindenkitől elveszi a hozzáférést', function () {
deploymentAllowEnvironment();
Config::set('deployment.enabled', false);
$developer = deploymentDeveloper();
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
->and(app(DeploymentGuard::class)->denialReason($developer))->toContain('DEPLOYMENT_PACKAGE_ENABLED');
});
test('a nem fejlesztői stage-en akkor sem érhető el, ha a kapcsoló be van kapcsolva', function () {
deploymentAllowEnvironment();
Config::set('app.stage', 'PROD');
$developer = deploymentDeveloper();
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
->and(app(DeploymentGuard::class)->denialReason($developer))->toContain('fejlesztői környezetben');
});
test('a developer szerep nélküli felhasználó nem éri el', function () {
deploymentAllowEnvironment();
Role::create(['name' => 'admin', 'display_name' => 'Admin']);
$admin = User::factory()->create();
$admin->addRole(Role::where('name', 'admin')->first());
FeatureFlag::create([
'name' => 'DeploymentPackage',
'label' => 'Deployment csomagoló',
'enabled' => true,
'stages' => null,
'roles' => null,
]);
app(FeatureFlagRegistrar::class)->registerAll();
expect(app(DeploymentGuard::class)->isAllowed($admin))->toBeFalse();
});
test('a kikapcsolt feature flag elrejti a felületet a fejlesztő elől is', function () {
deploymentAllowEnvironment();
$developer = deploymentDeveloper(flagEnabled: false);
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
->and(DeploymentPackage::canAccess())->toBeFalse();
});
test('minden feltétel teljesülése esetén engedélyezett', function () {
deploymentAllowEnvironment();
$developer = deploymentDeveloper();
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeTrue()
->and(app(DeploymentGuard::class)->denialReason($developer))->toBeNull();
});
test('a jogosulatlan felhasználó 403-at kap az oldal betöltésekor', function () {
deploymentAllowEnvironment();
$developer = deploymentDeveloper(flagEnabled: false);
// Szándékosan valódi HTTP kérés: a Livewire::test() a HttpException-t válasszá
// alakítja (RequestBroker: withoutExceptionHandling([HttpException::class])), így
// ott nem a tényleges belépési pontot mérnénk.
$this->actingAs($developer)
->get(DeploymentPackage::getUrl())
->assertForbidden();
});
test('a commitlista a legfrissebbtől visszafelé, fájlszámmal együtt jön', function () {
$repository = deploymentTestRepository();
$commits = app(GitRepository::class)->commits('main', 10);
expect($commits)->toHaveCount(3)
->and($commits[0]['subject'])->toBe('harmadik commit')
->and($commits[2]['subject'])->toBe('első commit')
->and($commits[0]['hash'])->toHaveLength(40)
->and($commits[0]['file_count'])->toBe(2)
// A törzs a tárgysor nélkül jön, és nem tapad hozzá a --shortstat sora sem.
->and($commits[1]['body'])->toBe('A törzsben említett árlista részlet.')
->and($commits[0]['body'])->toBe('');
deploymentCleanup($repository['path']);
});
test('a keresés a commit törzsében is talál, nem csak a tárgysorban', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
$this->actingAs(deploymentDeveloper());
Livewire::test(DeploymentPackage::class)
->set('data.search', 'árlista részlet')
->assertSee('második commit')
->assertDontSee('harmadik commit');
deploymentCleanup($repository['path']);
});
test('a diff felismeri az új, módosult, törölt és átnevezett fájlokat', function () {
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$files = collect(app(GitRepository::class)->changedFiles($first, $third))->keyBy('path');
expect($files->get('app/Elso.php')['status'])->toBe('M')
->and($files->get('app/Regi.php')['status'])->toBe('D')
->and($files->get('app/Atnevezett.php')['status'])->toBe('R')
->and($files->get('app/Atnevezett.php')['old_path'])->toBe('app/Atnevezendo.php')
// Ékezetes útvonal: core.quotePath=false nélkül "app/\303\201rlista.php" jönne vissza.
->and($files->has('app/Árlista.php'))->toBeTrue();
deploymentCleanup($repository['path']);
});
test('az elemzés kizárja a tiltott fájlokat és külön kezeli a törlendőket', function () {
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$changedFiles = app(GitRepository::class)->changedFiles($first, $third);
$changeSet = app(ChangeSetAnalyzer::class)->analyze($changedFiles);
expect($changeSet['copied'])->toContain('app/Árlista.php', 'app/Atnevezett.php', 'app/Elso.php')
->and($changeSet['copied'])->not->toContain('.env')
->and($changeSet['counts']['excluded'])->toBe(1)
// A törölt fájl mellett az átnevezés régi útvonala is törlendő a célgépen.
->and($changeSet['deleted'])->toContain('app/Regi.php', 'app/Atnevezendo.php');
$titles = collect($changeSet['warnings'])->pluck('title')->implode(' | ');
expect($titles)->toContain('Migráció')
->and($titles)->toContain('Seeder')
->and($titles)->toContain('Törlendő fájl');
deploymentCleanup($repository['path']);
});
test('a teendők kérik a queue worker és a scheduler újraindítását, a cache ürítés után', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$package = app(DeploymentPackageBuilder::class)->build('d2d', 'test', $first, $third);
$teendok = File::get($package['path'].'/_TEENDOK.md');
expect($teendok)->toContain('docker service update --force d2d-environment_queue')
->and($teendok)->toContain('docker service update --force d2d-environment_scheduler')
// A 0 replikás scheduler miatt tudni kell, hogy a restart üresjárat lehet.
->and($teendok)->toContain('0 replikán fut');
// A restartnak a cache ürítés UTÁN kell jönnie: a frissen induló worker különben
// a régi, cache-elt konfigot töltené be.
expect(strpos($teendok, 'docker service update'))->toBeGreaterThan(strpos($teendok, 'config:clear'));
deploymentCleanup($repository['path']);
});
test('a queue worker nélküli környezetnél is kiderül, mit kell majd újraindítani', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
$teendok = File::get($package['path'].'/_TEENDOK.md');
expect($teendok)->toContain('docker service update --force t2t_scheduler')
->and($teendok)->toContain('t2t_queue');
deploymentCleanup($repository['path']);
});
test('a teendők között szerepel a seeder futtatása a konkrét osztálynévvel', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
$teendok = File::get($package['path'].'/_TEENDOK.md');
expect($teendok)->toContain('php artisan db:seed --class=TesztSeeder')
// A seeder puszta felmásolása nem futtatja le - ezt ki kell mondani.
->and($teendok)->toContain('NEM futtatja le')
// A Pennant purge feltételes: Eloquent mentésnél a FeatureFlagObserver elvégzi.
->and($teendok)->toContain('pennant:purge')
->and($teendok)->toContain('cache:clear');
deploymentCleanup($repository['path']);
});
test('az érvénytelen commit azonosítót nem adja tovább a gitnek', function () {
$repository = deploymentTestRepository();
$git = app(GitRepository::class);
expect($git->resolveCommit('../../etc/passwd'))->toBeNull()
->and($git->resolveCommit('main; rm -rf /'))->toBeNull()
->and($git->resolveCommit('HEAD'))->toBeNull()
->and($git->resolveCommit($repository['commits'][0]))->toBe($repository['commits'][0]);
deploymentCleanup($repository['path']);
});
test('a fordított tartomány felismerhető', function () {
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$git = app(GitRepository::class);
expect($git->isAncestor($first, $third))->toBeTrue()
->and($git->isAncestor($third, $first))->toBeFalse();
deploymentCleanup($repository['path']);
});
test('az oldal kilistázza a commitokat és a kijelölt tartomány fájljait', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$this->actingAs(deploymentDeveloper());
Livewire::test(DeploymentPackage::class)
->assertSuccessful()
->assertSee('harmadik commit')
->call('selectFrom', $first)
->call('selectTo', $third)
->assertSet('fromCommit', $first)
->assertSet('toCommit', $third)
->assertSee('app/Atnevezett.php')
->assertSee('app/Regi.php');
deploymentCleanup($repository['path']);
});
test('a csomag a commitolt tartalmat viszi, nem a working tree állapotát', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
// A working tree szándékos elrontása a commit után: ez nem kerülhet a csomagba.
File::put($repository['path'].'/app/Árlista.php', "<?php\n// EZ NEM KERÜLHET A CSOMAGBA\n");
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
expect(File::get($package['path'].'/app/Árlista.php'))->not->toContain('NEM KERÜLHET')
// A .gitattributes eol=lf miatt a git archive LF-fel ír, Windowson is.
->and(File::get($package['path'].'/app/Árlista.php'))->not->toContain("\r\n");
deploymentCleanup($repository['path']);
});
test('a csomagból kimarad a kizárt és a kézzel kivett fájl, a törlendők külön listára kerülnek', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third, ['app/Elso.php']);
expect(File::exists($package['path'].'/app/Atnevezett.php'))->toBeTrue()
->and(File::exists($package['path'].'/app/Elso.php'))->toBeFalse()
->and(File::exists($package['path'].'/.env'))->toBeFalse()
->and(File::exists($package['path'].'/app/Regi.php'))->toBeFalse()
->and($package['counts']['skipped'])->toBe(1)
->and($package['counts']['missing'])->toBe(0);
$torlendo = File::get($package['path'].'/_TORLENDO.txt');
expect($torlendo)->toContain('app/Regi.php')
->and($torlendo)->toContain('app/Atnevezendo.php');
deploymentCleanup($repository['path']);
});
test('a csomag rögzíti a kirakott verziót', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
$version = json_decode(File::get($package['path'].'/public/deploy-version.json'), true);
expect($version['commit'])->toBe($third)
->and($version['target'])->toBe('e2e')
->and($version['branch'])->toBe('main');
deploymentCleanup($repository['path']);
});
test('a limit fölötti fájlszámnál nem készül csomag', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
Config::set('deployment.max_files', 1);
expect(fn () => app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third))
->toThrow(RuntimeException::class);
expect(File::exists(config('deployment.output_path')))->toBeFalse();
deploymentCleanup($repository['path']);
});
test('a csomagoló szerveren akkor sem indul el, ha közvetlenül hívják', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
Config::set('app.stage', 'PROD');
expect(fn () => app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third))
->toThrow(RuntimeException::class);
deploymentCleanup($repository['path']);
});
test('a felületről indított csomagolás létrehozza a mappát', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$this->actingAs(deploymentDeveloper());
$component = Livewire::test(DeploymentPackage::class)
->call('selectFrom', $first)
->call('selectTo', $third)
->call('toggleFile', 'app/Elso.php')
->call('createPackage');
$package = $component->get('lastPackage');
// Alapértelmezett célkörnyezet a d2d: azt frissítjük sűrűbben.
expect($package)->not->toBeNull()
->and($package['name'])->toStartWith('deploy_d2d_')
->and(File::isDirectory($package['path']))->toBeTrue()
->and($package['skipped'])->toBe(['app/Elso.php']);
deploymentCleanup($repository['path']);
});
test('a csomagolás naplóbejegyzést és letölthető ZIP-et hagy maga után', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$this->actingAs(deploymentDeveloper());
Livewire::test(DeploymentPackage::class)
->call('selectFrom', $first)
->call('selectTo', $third)
->call('createPackage');
$record = DeploymentPackageRecord::query()->latest('id')->first();
expect($record)->not->toBeNull()
->and($record->target)->toBe('d2d')
->and($record->to_commit)->toBe($third)
->and($record->deployed_at)->toBeNull()
->and(File::exists($record->zip_path))->toBeTrue();
Livewire::test(DeploymentPackage::class)
->call('downloadPackage', $record->id)
->assertFileDownloaded();
deploymentCleanup($repository['path']);
});
test('a kirakottként megjelölt csomag adja a következő tartomány kezdetét', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, $second, $third] = $repository['commits'];
$this->actingAs(deploymentDeveloper());
// Az alapértelmezett célkörnyezetre készül, hogy a második oldalbetöltés is ezt töltse elő.
$record = DeploymentPackageRecord::create([
'name' => 'deploy_d2d_teszt',
'target' => 'd2d',
'branch' => 'main',
'from_commit' => $first,
'to_commit' => $second,
'folder' => $repository['path'],
]);
Livewire::test(DeploymentPackage::class)
->call('markDeployed', $record->id)
->assertSet('fromCommit', $second);
expect($record->refresh()->deployed_at)->not->toBeNull();
// Új oldalbetöltésnél is a kirakott commitról indul a tartomány.
Livewire::test(DeploymentPackage::class)->assertSet('fromCommit', $second);
deploymentCleanup($repository['path']);
});
test('a kimeneti mappán kívüli ZIP útvonal nem tölthető le', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$this->actingAs(deploymentDeveloper());
// Elrontott (vagy szándékosan átírt) rekord: a fájl létezik, de a csomagoló
// kimeneti mappáján kívül van.
$record = DeploymentPackageRecord::create([
'name' => 'deploy_e2e_hamis',
'target' => 'e2e',
'branch' => 'main',
'from_commit' => $first,
'to_commit' => $third,
'folder' => $repository['path'],
'zip_path' => base_path('composer.json'),
]);
Livewire::test(DeploymentPackage::class)
->call('downloadPackage', $record->id)
->assertNoFileDownloaded();
deploymentCleanup($repository['path']);
});
test('a törlő szkript dry runban fut és ellenőrzi az app gyökeret', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
$script = File::get($package['path'].'/_torles.sh');
expect($script)->toStartWith('#!/usr/bin/env bash')
->and($script)->toContain('[[ -f artisan && -d app ]]')
->and($script)->toContain('"app/Regi.php"')
// Windowson generáljuk, Linuxon fut: CRLF-fel a shebang sor törne el.
->and($script)->not->toContain("\r\n");
deploymentCleanup($repository['path']);
});
test('az oldal alapból a d2d környezetet ajánlja és betöltéskor lekérdezi az élő állapotot', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[, , $third] = $repository['commits'];
// A beforeEach-ben regisztrált catch-all stub minden URL-re illeszkedik, és a
// Http factory az ELSŐ találatot adja vissza - ezért itt új factory kell, különben
// az üres törzsű alapértelmezett válasz nyerne a konkrét stub helyett.
Http::swap(new Factory);
Http::fake([
'*deploy-version.json' => Http::response(['target' => 'd2d', 'commit' => $third, 'branch' => 'main']),
]);
$this->actingAs(deploymentDeveloper());
$component = Livewire::test(DeploymentPackage::class);
expect($component->get('data')['target'])->toBe('d2d')
// A lekérdezés gomb nélkül, magától megtörténik.
->and($component->get('liveVersions')['d2d']['ok'])->toBeTrue()
->and($component->get('liveVersions')['d2d']['data']['commit'])->toBe($third);
deploymentCleanup($repository['path']);
});
test('az oldal nem fogad el kamu commit azonosítót', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
$this->actingAs(deploymentDeveloper());
Livewire::test(DeploymentPackage::class)
->call('selectFrom', '../../etc/passwd')
->assertSet('fromCommit', null);
deploymentCleanup($repository['path']);
});