feat(uptelligence): add AltumCode vendor update checks

Add automated version checking for AltumCode products and plugins:
- isAltumPlatform() routes altum vendors before OSS checks
- checkAltumProduct() fetches latest_release_version from product info.php
- checkAltumPlugin() looks up plugin versions from dev.altumcode.com
- In-memory cache avoids redundant HTTP calls for multiple plugins

14 Pest tests covering all paths (43 assertions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-12 08:40:26 +00:00
parent cc1918b511
commit f0a2f3fd1a
2 changed files with 496 additions and 2 deletions

View file

@ -6,6 +6,8 @@ namespace Core\Mod\Uptelligence\Services;
use Core\Mod\Uptelligence\Models\UpstreamTodo; use Core\Mod\Uptelligence\Models\UpstreamTodo;
use Core\Mod\Uptelligence\Models\Vendor; use Core\Mod\Uptelligence\Models\Vendor;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@ -17,6 +19,13 @@ use Illuminate\Support\Facades\RateLimiter;
*/ */
class VendorUpdateCheckerService class VendorUpdateCheckerService
{ {
/**
* In-memory cache for AltumCode plugin versions.
*
* Avoids redundant HTTP calls when checking multiple plugins in one run.
*/
protected ?array $altumPluginVersionsCache = null;
/** /**
* Check all active vendors for updates. * Check all active vendors for updates.
* *
@ -42,6 +51,8 @@ class VendorUpdateCheckerService
{ {
// Determine check method based on source type and git URL // Determine check method based on source type and git URL
$result = match (true) { $result = match (true) {
$this->isAltumPlatform($vendor) && $vendor->isLicensed() => $this->checkAltumProduct($vendor),
$this->isAltumPlatform($vendor) && $vendor->isPlugin() => $this->checkAltumPlugin($vendor),
$vendor->isOss() && $this->isGitHubUrl($vendor->git_repo_url) => $this->checkGitHub($vendor), $vendor->isOss() && $this->isGitHubUrl($vendor->git_repo_url) => $this->checkGitHub($vendor),
$vendor->isOss() && $this->isGiteaUrl($vendor->git_repo_url) => $this->checkGitea($vendor), $vendor->isOss() && $this->isGiteaUrl($vendor->git_repo_url) => $this->checkGitea($vendor),
default => $this->skipCheck($vendor), default => $this->skipCheck($vendor),
@ -89,10 +100,10 @@ class VendorUpdateCheckerService
->retry(3, function (int $attempt) { ->retry(3, function (int $attempt) {
return (int) pow(2, $attempt - 1) * 1000; return (int) pow(2, $attempt - 1) * 1000;
}, function (\Exception $exception) { }, function (\Exception $exception) {
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { if ($exception instanceof ConnectionException) {
return true; return true;
} }
if ($exception instanceof \Illuminate\Http\Client\RequestException) { if ($exception instanceof RequestException) {
$status = $exception->response?->status(); $status = $exception->response?->status();
return $status >= 500 || $status === 429; return $status >= 500 || $status === 429;
@ -273,6 +284,125 @@ class VendorUpdateCheckerService
); );
} }
/**
* Check if vendor belongs to the AltumCode platform.
*/
protected function isAltumPlatform(Vendor $vendor): bool
{
return $vendor->plugin_platform === Vendor::PLATFORM_ALTUM;
}
/**
* Check an AltumCode licensed product for updates.
*
* Fetches the product's info.php endpoint (e.g. https://66analytics.com/info.php)
* and reads the latest_release_version from the JSON response.
*/
protected function checkAltumProduct(Vendor $vendor): array
{
$url = "https://{$vendor->slug}.com/info.php";
try {
$response = Http::timeout(5)->get($url);
} catch (\Exception $e) {
Log::warning('Uptelligence: AltumCode product check failed', [
'vendor' => $vendor->slug,
'url' => $url,
'error' => $e->getMessage(),
]);
return $this->errorResult("AltumCode product check failed: {$e->getMessage()}");
}
if (! $response->successful()) {
Log::warning('Uptelligence: AltumCode product info request failed', [
'vendor' => $vendor->slug,
'status' => $response->status(),
]);
return $this->errorResult("AltumCode product info error: {$response->status()}");
}
$data = $response->json();
$latestVersion = $this->normaliseVersion($data['latest_release_version'] ?? '');
return $this->buildResult(
vendor: $vendor,
latestVersion: $latestVersion,
releaseInfo: [
'source' => 'altum_product',
'url' => $url,
]
);
}
/**
* Check an AltumCode plugin for updates.
*
* Fetches the plugin versions endpoint and looks up the plugin by ID
* (stripping the 'altum-plugin-' prefix from the vendor slug).
*/
protected function checkAltumPlugin(Vendor $vendor): array
{
$versions = $this->getAltumPluginVersions();
if ($versions === null) {
return $this->errorResult('Failed to fetch AltumCode plugin versions');
}
// Strip 'altum-plugin-' prefix from slug to get the plugin ID
$pluginId = str_replace('altum-plugin-', '', $vendor->slug);
if (! isset($versions[$pluginId])) {
return $this->errorResult("Plugin '{$pluginId}' not found in AltumCode registry");
}
$latestVersion = $this->normaliseVersion($versions[$pluginId]['version'] ?? '');
return $this->buildResult(
vendor: $vendor,
latestVersion: $latestVersion,
releaseInfo: [
'source' => 'altum_plugin',
'plugin_id' => $pluginId,
]
);
}
/**
* Fetch AltumCode plugin versions with in-memory caching.
*
* @return array<string, array{version: string}>|null
*/
protected function getAltumPluginVersions(): ?array
{
if ($this->altumPluginVersionsCache !== null) {
return $this->altumPluginVersionsCache;
}
try {
$response = Http::timeout(5)->get('https://dev.altumcode.com/plugins-versions');
} catch (\Exception $e) {
Log::warning('Uptelligence: AltumCode plugin versions fetch failed', [
'error' => $e->getMessage(),
]);
return null;
}
if (! $response->successful()) {
Log::warning('Uptelligence: AltumCode plugin versions request failed', [
'status' => $response->status(),
]);
return null;
}
$this->altumPluginVersionsCache = $response->json() ?? [];
return $this->altumPluginVersionsCache;
}
/** /**
* Skip check for vendors that don't support auto-checking. * Skip check for vendors that don't support auto-checking.
*/ */

View file

@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
use Core\Mod\Uptelligence\Models\Vendor;
use Core\Mod\Uptelligence\Services\VendorUpdateCheckerService;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->service = new VendorUpdateCheckerService;
});
// ---------------------------------------------------------------------------
// isAltumPlatform
// ---------------------------------------------------------------------------
it('identifies altum platform vendors', function () {
$vendor = new Vendor([
'slug' => '66analytics',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
]);
$method = new ReflectionMethod($this->service, 'isAltumPlatform');
$method->setAccessible(true);
expect($method->invoke($this->service, $vendor))->toBeTrue();
});
it('rejects non-altum platform vendors', function () {
$vendor = new Vendor([
'slug' => 'some-wp-plugin',
'plugin_platform' => Vendor::PLATFORM_WORDPRESS,
]);
$method = new ReflectionMethod($this->service, 'isAltumPlatform');
$method->setAccessible(true);
expect($method->invoke($this->service, $vendor))->toBeFalse();
});
it('rejects vendors with no platform set', function () {
$vendor = new Vendor([
'slug' => 'some-oss',
'plugin_platform' => null,
]);
$method = new ReflectionMethod($this->service, 'isAltumPlatform');
$method->setAccessible(true);
expect($method->invoke($this->service, $vendor))->toBeFalse();
});
// ---------------------------------------------------------------------------
// checkAltumProduct
// ---------------------------------------------------------------------------
it('fetches latest version from altum product info endpoint', function () {
Http::fake([
'https://66analytics.com/info.php' => Http::response([
'latest_release_version' => '42.0.0',
'product_name' => '66analytics',
], 200),
]);
$vendor = new Vendor([
'slug' => '66analytics',
'name' => '66analytics',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
'source_type' => Vendor::SOURCE_LICENSED,
'current_version' => '41.0.0',
]);
$method = new ReflectionMethod($this->service, 'checkAltumProduct');
$method->setAccessible(true);
$result = $method->invoke($this->service, $vendor);
expect($result)
->toBeArray()
->and($result['status'])->toBe('success')
->and($result['latest'])->toBe('42.0.0')
->and($result['has_update'])->toBeTrue()
->and($result['release_info']['source'])->toBe('altum_product');
});
it('detects no update when product version is current', function () {
Http::fake([
'https://66biolinks.com/info.php' => Http::response([
'latest_release_version' => '39.0.0',
], 200),
]);
$vendor = new Vendor([
'slug' => '66biolinks',
'name' => '66biolinks',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
'source_type' => Vendor::SOURCE_LICENSED,
'current_version' => '39.0.0',
]);
$method = new ReflectionMethod($this->service, 'checkAltumProduct');
$method->setAccessible(true);
$result = $method->invoke($this->service, $vendor);
expect($result['status'])->toBe('success')
->and($result['has_update'])->toBeFalse()
->and($result['latest'])->toBe('39.0.0');
});
it('returns error when altum product endpoint fails', function () {
Http::fake([
'https://66analytics.com/info.php' => Http::response('Server Error', 500),
]);
$vendor = new Vendor([
'slug' => '66analytics',
'name' => '66analytics',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
'source_type' => Vendor::SOURCE_LICENSED,
'current_version' => '40.0.0',
]);
$method = new ReflectionMethod($this->service, 'checkAltumProduct');
$method->setAccessible(true);
$result = $method->invoke($this->service, $vendor);
expect($result['status'])->toBe('error')
->and($result['message'])->toContain('500');
});
it('returns error when altum product has no version in response', function () {
Http::fake([
'https://66pusher.com/info.php' => Http::response([
'product_name' => '66pusher',
// No latest_release_version key
], 200),
]);
$vendor = new Vendor([
'slug' => '66pusher',
'name' => '66pusher',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
'source_type' => Vendor::SOURCE_LICENSED,
'current_version' => '5.0.0',
]);
$method = new ReflectionMethod($this->service, 'checkAltumProduct');
$method->setAccessible(true);
$result = $method->invoke($this->service, $vendor);
expect($result['status'])->toBe('error')
->and($result['message'])->toContain('Could not determine latest version');
});
// ---------------------------------------------------------------------------
// checkAltumPlugin
// ---------------------------------------------------------------------------
it('fetches latest version for an altum plugin', function () {
Http::fake([
'https://dev.altumcode.com/plugins-versions' => Http::response([
'affiliate' => ['version' => '2.0.1'],
'email-notifications' => ['version' => '3.1.0'],
], 200),
]);
$vendor = new Vendor([
'slug' => 'altum-plugin-affiliate',
'name' => 'AltumCode Affiliate Plugin',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
'source_type' => Vendor::SOURCE_PLUGIN,
'current_version' => '1.5.0',
]);
$method = new ReflectionMethod($this->service, 'checkAltumPlugin');
$method->setAccessible(true);
$result = $method->invoke($this->service, $vendor);
expect($result['status'])->toBe('success')
->and($result['latest'])->toBe('2.0.1')
->and($result['has_update'])->toBeTrue()
->and($result['release_info']['source'])->toBe('altum_plugin')
->and($result['release_info']['plugin_id'])->toBe('affiliate');
});
it('returns error when plugin is not found in altum registry', function () {
Http::fake([
'https://dev.altumcode.com/plugins-versions' => Http::response([
'affiliate' => ['version' => '2.0.1'],
], 200),
]);
$vendor = new Vendor([
'slug' => 'altum-plugin-nonexistent',
'name' => 'Nonexistent Plugin',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
'source_type' => Vendor::SOURCE_PLUGIN,
'current_version' => '1.0.0',
]);
$method = new ReflectionMethod($this->service, 'checkAltumPlugin');
$method->setAccessible(true);
$result = $method->invoke($this->service, $vendor);
expect($result['status'])->toBe('error')
->and($result['message'])->toContain('nonexistent')
->and($result['message'])->toContain('not found');
});
it('returns error when plugin versions endpoint fails', function () {
Http::fake([
'https://dev.altumcode.com/plugins-versions' => Http::response('Bad Gateway', 502),
]);
$vendor = new Vendor([
'slug' => 'altum-plugin-affiliate',
'name' => 'AltumCode Affiliate Plugin',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
'source_type' => Vendor::SOURCE_PLUGIN,
'current_version' => '1.0.0',
]);
$method = new ReflectionMethod($this->service, 'checkAltumPlugin');
$method->setAccessible(true);
$result = $method->invoke($this->service, $vendor);
expect($result['status'])->toBe('error')
->and($result['message'])->toContain('Failed to fetch');
});
// ---------------------------------------------------------------------------
// In-memory cache
// ---------------------------------------------------------------------------
it('caches plugin versions across multiple calls', function () {
Http::fake([
'https://dev.altumcode.com/plugins-versions' => Http::response([
'affiliate' => ['version' => '2.0.1'],
'email-notifications' => ['version' => '3.1.0'],
], 200),
]);
$method = new ReflectionMethod($this->service, 'getAltumPluginVersions');
$method->setAccessible(true);
// First call — should hit the HTTP endpoint
$first = $method->invoke($this->service);
expect($first)->toBeArray()->toHaveKey('affiliate');
// Second call — should use cache (no additional HTTP request)
$second = $method->invoke($this->service);
expect($second)->toBe($first);
// Verify HTTP was only called once
Http::assertSentCount(1);
});
// ---------------------------------------------------------------------------
// Match routing via checkVendor (integration-level)
// ---------------------------------------------------------------------------
it('routes altum licensed vendor through checkAltumProduct', function () {
Http::fake([
'https://66analytics.com/info.php' => Http::response([
'latest_release_version' => '42.0.0',
], 200),
]);
$vendor = Mockery::mock(Vendor::class)->makePartial();
$vendor->shouldReceive('getAttribute')->with('slug')->andReturn('66analytics');
$vendor->shouldReceive('getAttribute')->with('name')->andReturn('66analytics');
$vendor->shouldReceive('getAttribute')->with('plugin_platform')->andReturn(Vendor::PLATFORM_ALTUM);
$vendor->shouldReceive('getAttribute')->with('source_type')->andReturn(Vendor::SOURCE_LICENSED);
$vendor->shouldReceive('getAttribute')->with('current_version')->andReturn('41.0.0');
$vendor->shouldReceive('getAttribute')->with('git_repo_url')->andReturn(null);
$vendor->shouldReceive('update')->once()->with(Mockery::on(function ($data) {
return array_key_exists('last_checked_at', $data);
}));
$vendor->shouldReceive('isLicensed')->andReturn(true);
$vendor->shouldReceive('isPlugin')->andReturn(false);
$vendor->shouldReceive('isOss')->andReturn(false);
// Use a partial mock of the service to prevent createUpdateTodo from hitting DB
$service = Mockery::mock(VendorUpdateCheckerService::class)->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('createUpdateTodo')->once();
$result = $service->checkVendor($vendor);
expect($result['status'])->toBe('success')
->and($result['latest'])->toBe('42.0.0')
->and($result['has_update'])->toBeTrue();
Http::assertSent(function ($request) {
return $request->url() === 'https://66analytics.com/info.php';
});
});
it('routes altum plugin vendor through checkAltumPlugin', function () {
Http::fake([
'https://dev.altumcode.com/plugins-versions' => Http::response([
'affiliate' => ['version' => '2.0.1'],
], 200),
]);
$vendor = Mockery::mock(Vendor::class)->makePartial();
$vendor->shouldReceive('getAttribute')->with('slug')->andReturn('altum-plugin-affiliate');
$vendor->shouldReceive('getAttribute')->with('name')->andReturn('Affiliate Plugin');
$vendor->shouldReceive('getAttribute')->with('plugin_platform')->andReturn(Vendor::PLATFORM_ALTUM);
$vendor->shouldReceive('getAttribute')->with('source_type')->andReturn(Vendor::SOURCE_PLUGIN);
$vendor->shouldReceive('getAttribute')->with('current_version')->andReturn('1.5.0');
$vendor->shouldReceive('getAttribute')->with('git_repo_url')->andReturn(null);
$vendor->shouldReceive('update')->once()->with(Mockery::on(function ($data) {
return array_key_exists('last_checked_at', $data);
}));
$vendor->shouldReceive('isLicensed')->andReturn(false);
$vendor->shouldReceive('isPlugin')->andReturn(true);
$vendor->shouldReceive('isOss')->andReturn(false);
// Use a partial mock of the service to prevent createUpdateTodo from hitting DB
$service = Mockery::mock(VendorUpdateCheckerService::class)->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('createUpdateTodo')->once();
$result = $service->checkVendor($vendor);
expect($result['status'])->toBe('success')
->and($result['latest'])->toBe('2.0.1')
->and($result['has_update'])->toBeTrue();
Http::assertSent(function ($request) {
return $request->url() === 'https://dev.altumcode.com/plugins-versions';
});
});
it('normalises version with v prefix from altum product', function () {
Http::fake([
'https://66socialproof.com/info.php' => Http::response([
'latest_release_version' => 'v7.0.0',
], 200),
]);
$vendor = new Vendor([
'slug' => '66socialproof',
'name' => '66socialproof',
'plugin_platform' => Vendor::PLATFORM_ALTUM,
'source_type' => Vendor::SOURCE_LICENSED,
'current_version' => '6.0.0',
]);
$method = new ReflectionMethod($this->service, 'checkAltumProduct');
$method->setAccessible(true);
$result = $method->invoke($this->service, $vendor);
expect($result['latest'])->toBe('7.0.0')
->and($result['has_update'])->toBeTrue();
});