diff --git a/app/Exceptions/UpdateException.php b/app/Exceptions/UpdateException.php new file mode 100644 index 0000000..58b3390 --- /dev/null +++ b/app/Exceptions/UpdateException.php @@ -0,0 +1,24 @@ +updateStep = $updateStep; + + parent::__construct($message, $code, $previous); + } + + public function getStep(): string + { + return $this->updateStep; + } +} diff --git a/app/Jobs/UpdateSite.php b/app/Jobs/UpdateSite.php index 3cfcff4..669077b 100644 --- a/app/Jobs/UpdateSite.php +++ b/app/Jobs/UpdateSite.php @@ -4,7 +4,9 @@ namespace App\Jobs; +use App\Exceptions\UpdateException; use App\Models\Site; +use App\Models\Update; use App\RemoteSite\Connection; use App\RemoteSite\Responses\PrepareUpdate; use Illuminate\Contracts\Queue\ShouldQueue; @@ -65,16 +67,31 @@ public function handle(): void $prepareResult = $connection->prepareUpdate(["targetVersion" => $this->targetVersion]); // Perform the actual extraction - $this->performExtraction($prepareResult); + try { + $this->performExtraction($prepareResult); + } catch (\Throwable $e) { + throw new UpdateException( + 'extract', + $e->getMessage(), + (int) $e->getCode(), + $e instanceof \Exception ? $e : null + ); + } // Run the postupdate steps if (!$connection->finalizeUpdate()->success) { - throw new \Exception("Update for site failed in postprocessing: " . $this->site->id); + throw new UpdateException( + "finalize", + "Update for site failed in postprocessing: " . $this->site->id + ); } // Compare codes if ($this->site->getFrontendStatus() !== $this->preUpdateCode) { - throw new \Exception("Status code has changed after update for site: " . $this->site->id); + throw new UpdateException( + "afterUpdate", + "Status code has changed after update for site: " . $this->site->id + ); } } @@ -121,5 +138,25 @@ protected function performExtraction(PrepareUpdate $prepareResult): void "task" => "finalizeUpdate" ] ); + + // Done, log successful update! + $this->site->updates()->create([ + 'old_version' => $this->site->cms_version, + 'new_version' => $this->targetVersion, + 'result' => true + ]); + } + + public function failed(\Exception $exception): void + { + // We log any issues during the update to the DB + $this->site->updates()->create([ + 'old_version' => $this->site->cms_version, + 'new_version' => $this->targetVersion, + 'result' => false, + 'failed_step' => $exception instanceof UpdateException ? $exception->getStep() : null, + 'failed_message' => $exception->getMessage(), + 'failed_trace' => $exception->getTraceAsString() + ]); } } diff --git a/app/Models/Site.php b/app/Models/Site.php index c38481b..37dc94f 100644 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -7,6 +7,7 @@ use App\RemoteSite\Connection; use GuzzleHttp\Client; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\App; class Site extends Model @@ -55,4 +56,12 @@ public function getFrontendStatus(): int return $httpClient->get($this->url)->getStatusCode(); } + + /** + * @return HasMany + */ + public function updates(): HasMany + { + return $this->hasMany(Update::class, 'site_id', 'id'); + } } diff --git a/app/Models/Update.php b/app/Models/Update.php new file mode 100644 index 0000000..228058c --- /dev/null +++ b/app/Models/Update.php @@ -0,0 +1,26 @@ + 'bool' + ]; + } +} diff --git a/database/migrations/2025_01_18_114039_add_updates_table.php b/database/migrations/2025_01_18_114039_add_updates_table.php new file mode 100644 index 0000000..fbefc09 --- /dev/null +++ b/database/migrations/2025_01_18_114039_add_updates_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('site_id'); + $table->string('old_version'); + $table->string('new_version'); + $table->boolean('result'); + $table->string('failed_step')->nullable(); + $table->text('failed_message')->nullable(); + $table->text('failed_trace')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('updates'); + } +}; diff --git a/tests/Unit/Jobs/UpdateSiteTest.php b/tests/Unit/Jobs/UpdateSiteTest.php index a6cb236..bb84892 100644 --- a/tests/Unit/Jobs/UpdateSiteTest.php +++ b/tests/Unit/Jobs/UpdateSiteTest.php @@ -2,19 +2,24 @@ namespace Tests\Unit\Jobs; +use App\Exceptions\UpdateException; use App\Jobs\UpdateSite; use App\Models\Site; +use App\Models\Update; use App\RemoteSite\Connection; use App\RemoteSite\Responses\FinalizeUpdate; use App\RemoteSite\Responses\GetUpdate; use App\RemoteSite\Responses\HealthCheck; use App\RemoteSite\Responses\PrepareUpdate; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Tests\TestCase; class UpdateSiteTest extends TestCase { + use RefreshDatabase; + public function testJobQuitsIfTargetVersionIsEqualOrNewer() { $site = $this->getSiteMock(['checkHealth' => $this->getHealthCheckMock(["cms_version" => "1.0.0"])]); @@ -98,6 +103,52 @@ public function testJobFailsIfFinalizeUpdateReturnsFalse() $object->handle(); } + public function testJobWritesFailLogOnFailing() + { + $siteMock = $this->getMockBuilder(Site::class) + ->onlyMethods(['getConnectionAttribute', 'getFrontendStatus']) + ->getMock(); + + $siteMock->id = 1; + $siteMock->url = "http://example.org"; + $siteMock->cms_version = "1.0.0"; + + $object = new UpdateSite($siteMock, "1.0.1"); + $object->failed(new UpdateException("finalize", "This is a test")); + + $failedUpdate = Update::first(); + + $this->assertEquals(false, $failedUpdate->result); + $this->assertEquals("1.0.0", $failedUpdate->old_version); + $this->assertEquals("1.0.1", $failedUpdate->new_version); + $this->assertEquals("finalize", $failedUpdate->failed_step); + $this->assertEquals("This is a test", $failedUpdate->failed_message); + $this->assertNotEmpty($failedUpdate->failed_trace); + } + + public function testJobWritesSuccessLogForSuccessfulJobs() + { + $site = $this->getSiteMock( + [ + 'checkHealth' => $this->getHealthCheckMock(), + 'getUpdate' => $this->getGetUpdateMock("1.0.1"), + 'prepareUpdate' => $this->getPrepareUpdateMock(), + 'finalizeUpdate' => $this->getFinalizeUpdateMock(true) + ] + ); + + App::bind(Connection::class, fn () => $this->getSuccessfulExtractionMock()); + + $object = new UpdateSite($site, "1.0.1"); + $object->handle(); + + $updateRow = Update::first(); + + $this->assertEquals(true, $updateRow->result); + $this->assertEquals("1.0.0", $updateRow->old_version); + $this->assertEquals("1.0.1", $updateRow->new_version); + } + protected function getSiteMock(array $responses) { $connectionMock = $this->getMockBuilder(Connection::class) @@ -120,6 +171,7 @@ function ($method) use ($responses) { $siteMock->method('getFrontendStatus')->willReturn(200); $siteMock->id = 1; $siteMock->url = "http://example.org"; + $siteMock->cms_version = "1.0.0"; return $siteMock; }