setCeiling('chef@example.com', 'v1.8.0'))->toBeTrue() ->and(trim(File::get($path)))->toBe('v1.8.0'); expect($channel->setCeiling('chef@example.com', null))->toBeTrue() ->and(File::exists($path))->toBeFalse(); }); it('refuses a ceiling that is not a version', function () { // Die Form wird auf BEIDEN Seiten geprueft. Der Agent kann sich auf nichts // verlassen, was aus einer Datei kommt — und die Konsole soll gar nicht // erst etwas hinlegen, das er ablehnen muss. $channel = app(UpdateChannel::class); expect($channel->setCeiling('chef@example.com', 'neueste'))->toBeFalse() ->and(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse(); }); it('leaves no temporary file behind', function () { // Atomar heisst: der Agent sieht die Datei ganz oder gar nicht. Bliebe die // .tmp liegen, waere das der Beleg fuer ein File::put statt eines rename. app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); expect(File::exists(storage_path('app/deploy/release-ceiling.tmp')))->toBeFalse(); }); it('reports the ceiling from the file, not from the agent', function () { // Die Decke ist der Konsole eigener Zustand. Sie muss sofort stehen, statt // zu warten, bis der Agent sie zurueckmeldet. app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); expect(app(UpdateChannel::class)->state()['ceiling'])->toBe('v1.8.0'); }); it('passes the ceiling complaint from the agent through', function () { File::put(storage_path('app/deploy/update-status.json'), json_encode([ 'state' => 'idle', 'ceiling_error' => 'ceiling_missing', 'releases' => ['v1.8.1', 'v1.8.0'], ])); $state = app(UpdateChannel::class)->state(); expect($state['ceiling_error'])->toBe('ceiling_missing') ->and($state['releases'])->toBe(['v1.8.1', 'v1.8.0']); }); it('marks a ceiling that sits below what is deployed', function () { // Über das Auswahlfeld nicht erreichbar, über die Kommandozeile schon: // Decke auf v1.8.0, jemand fährt `RELEASE=v1.8.1 update.sh`. Die Lage // muss lesbar sein statt als „aktuell" durchzugehen — es steht ja eine // Decke, sie ist nur überholt. File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0'); $state = app(UpdateChannel::class)->state(); // `version` kommt aus Release::current(); in der Testumgebung ist das die // VERSION-Datei des Checkouts, die über 1.8.0 liegt. expect($state['ceiling_passed'])->toBeTrue(); }); it('does not mark a ceiling that is still ahead', function () { File::put(storage_path('app/deploy/release-ceiling'), 'v99.0.0'); expect(app(UpdateChannel::class)->state()['ceiling_passed'])->toBeFalse(); }); it('does not throw when the ceiling file exists but cannot be read as a file', function () { // Kein echtes Wettrennen im Test moeglich, aber derselbe Codepfad wie ein // Verschwinden zwischen File::exists() und File::get(): ein Verzeichnis // existiert (File::exists() prueft file_exists(), das ist fuer // Verzeichnisse wahr), ist aber keine Datei (File::get() prueft // isFile() und wirft eine FileNotFoundException). Ohne Absicherung // wuerfe state() genau hier — die Vorgabe, die es unter keinen // Umstaenden darf. // // Bewusst kein expect(fn () => ...)->not->toThrow(Throwable::class): // Throwable ist ein Interface, class_exists() verneint es, und die // Pruefung faellt intern auf einen Substring-Vergleich zurueck, der // nichts mehr ueber ein tatsaechliches Werfen aussagt. Ein direkter // Aufruf laesst eine durchschlagende Exception den Test regulaer als // fehlgeschlagen melden — das ist der verlaessliche Nachweis. File::ensureDirectoryExists(storage_path('app/deploy/release-ceiling')); $state = app(UpdateChannel::class)->state(); expect($state['ceiling'])->toBeNull(); }); it('does not claim success when writing the ceiling fails', function () { // Kein File-Facade-Mock: eine echte Rechteverweigerung auf einem // Verzeichnis, das dieser (nicht-root) Testbenutzer selbst besitzt. // File::put() auf die .tmp darin schlaegt wirklich fehl, nicht nur dem // Namen nach. $deployDir = storage_path('app/deploy'); chmod($deployDir, 0500); try { $result = app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); } finally { chmod($deployDir, 0755); } expect($result)->toBeFalse() ->and(File::exists($deployDir.'/release-ceiling.tmp'))->toBeFalse() ->and(File::exists($deployDir.'/release-ceiling'))->toBeFalse(); }); it('makes the automatic window do nothing while the ceiling is reached', function () { // Der wichtigste Test des ganzen Entwurfs. Er belegt, dass Knopf und // Wartungsfenster denselben Zustand lesen — die Behauptung, auf der alles // ruht. Ohne ihn ist „es gibt keinen zweiten Weg in eine Auslieferung" // eine Absichtserklaerung im Kommentar von AutoUpdate.php, kein Beleg. // // Damit ALLEIN die Decke den Ausschlag gibt, muessen alle anderen // Vorbedingungen aus AutoUpdate::handle() erfuellt sein: das Fenster ist // eingeschaltet (--force uebergeht nur isOpen(), nicht enabled() — ein // ausgeschaltetes Fenster wuerde den Test aus dem falschen Grund gruen // machen), der Agent gilt als lebendig, und es laeuft nichts. Der Agent // hat gemeldet: nichts verfuegbar, weil die Decke erreicht ist. AppSettings::set(UpdateWindow::KEY_ENABLED, true); File::put(storage_path('app/deploy/update-status.json'), json_encode([ 'state' => 'idle', 'behind' => 0, 'ceiling_error' => '', 'checked_at' => now()->toIso8601String(), ])); File::put(storage_path('app/deploy/agent-alive.json'), json_encode([ 'at' => now()->toIso8601String(), 'state' => 'running', 'skips' => 0, ])); // Der Zustand, den Knopf und Wartungsfenster BEIDE lesen: schon hier // steht `available` auf false, bevor der Befehl ueberhaupt laeuft. expect(app(UpdateChannel::class)->state()['available'])->toBeFalse(); $this->artisan('clupilot:auto-update', ['--force' => true]) ->assertSuccessful(); // Nichts abgelegt: keine Anfrage, kein Wartungsfenster, kein Lauf. expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); }); it('takes the same state without a ceiling, and does request an update', function () { // Der Gegenbeweis zum Test oben: exakt derselbe Aufbau — Fenster an, Agent // lebendig, nichts laeuft — nur `behind` steht auf 1 statt 0, so wie der // Agent es meldet, wenn keine Decke ihn bremst. Erst beide Tests // zusammen belegen, dass die Decke — und keine der anderen // Vorbedingungen — den Ausschlag gibt: ohne sie legt dieselbe Automatik // sehr wohl eine Anfrage ab. AppSettings::set(UpdateWindow::KEY_ENABLED, true); File::put(storage_path('app/deploy/update-status.json'), json_encode([ 'state' => 'idle', 'behind' => 1, 'ceiling_error' => '', 'checked_at' => now()->toIso8601String(), ])); File::put(storage_path('app/deploy/agent-alive.json'), json_encode([ 'at' => now()->toIso8601String(), 'state' => 'running', 'skips' => 0, ])); expect(app(UpdateChannel::class)->state()['available'])->toBeTrue(); $this->artisan('clupilot:auto-update', ['--force' => true]) ->assertSuccessful(); expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue(); });