mirror of
https://github.com/sp-tarkov/forge.git
synced 2025-02-12 12:10:41 -05:00
SPT Semvar & Automatic Resolution
This update gives mod versions a supported SPT version field that accepts a semantic version. The latest supported SPT version will be automatically resolved based on the semvar. Next up: I need to update the ModVersion to SptVersion relationship to be a many-to-many and expand the resolution to resolve multiple versions.
This commit is contained in:
parent
d1bfdf5424
commit
db578071e4
27
.env.full
27
.env.full
@ -31,18 +31,6 @@ DB_PASSWORD=password
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_0900_ai_ci
|
||||
|
||||
# This is only needed if you are running the `artisan app:import-hub` command.
|
||||
# For normal development you should just seed the database with fake data by
|
||||
# running the command: `php artisan migrate:fresh --seed`
|
||||
DB_HUB_CONNECTION=mysql
|
||||
DB_HUB_HOST=
|
||||
DB_HUB_PORT=
|
||||
DB_HUB_DATABASE=
|
||||
DB_HUB_USERNAME=
|
||||
DB_HUB_PASSWORD=
|
||||
DB_HUB_CHARSET=utf8mb4
|
||||
DB_HUB_COLLATION=utf8mb4_0900_ai_ci
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_CONNECTION=default
|
||||
SESSION_LIFETIME=120
|
||||
@ -86,3 +74,18 @@ OCTANE_SERVER=swoole
|
||||
OCTANE_HTTPS=false
|
||||
|
||||
SAIL_XDEBUG_MODE=develop,debug,coverage
|
||||
|
||||
# Everything below is only needed if you are running the `artisan app:import-hub` command.
|
||||
# For normal development you should just seed the database with fake data by
|
||||
# running the command: `php artisan migrate:fresh --seed`
|
||||
DB_HUB_CONNECTION=mysql
|
||||
DB_HUB_HOST=
|
||||
DB_HUB_PORT=
|
||||
DB_HUB_DATABASE=
|
||||
DB_HUB_USERNAME=
|
||||
DB_HUB_PASSWORD=
|
||||
DB_HUB_CHARSET=utf8mb4
|
||||
DB_HUB_COLLATION=utf8mb4_0900_ai_ci
|
||||
|
||||
GITEA_DOMAIN=
|
||||
GITEA_TOKEN=
|
||||
|
@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ImportHubData;
|
||||
use App\Jobs\ImportHubDataJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ImportHub extends Command
|
||||
class ImportHubCommand extends Command
|
||||
{
|
||||
protected $signature = 'app:import-hub';
|
||||
|
||||
@ -13,8 +13,7 @@ class ImportHub extends Command
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Add the ImportHubData job to the queue.
|
||||
ImportHubData::dispatch()->onQueue('long');
|
||||
ImportHubDataJob::dispatch()->onQueue('long');
|
||||
|
||||
$this->info('The import job has been added to the queue.');
|
||||
}
|
22
app/Console/Commands/ResolveVersionsCommand.php
Normal file
22
app/Console/Commands/ResolveVersionsCommand.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ResolveDependenciesJob;
|
||||
use App\Jobs\ResolveSptVersionsJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ResolveVersionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'app:resolve-versions';
|
||||
|
||||
protected $description = 'Resolve SPT and dependency versions for all mods.';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
ResolveSptVersionsJob::dispatch()->onQueue('long');
|
||||
ResolveDependenciesJob::dispatch()->onQueue('long');
|
||||
|
||||
$this->info('The import job has been added to the queue.');
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ namespace App\Console\Commands;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class SearchSync extends Command
|
||||
class SearchSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'app:search-sync';
|
||||
|
@ -6,7 +6,7 @@ use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class UploadAssets extends Command
|
||||
class UploadAssetsCommand extends Command
|
||||
{
|
||||
protected $signature = 'app:upload-assets';
|
||||
|
@ -4,7 +4,4 @@ namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class CircularDependencyException extends Exception
|
||||
{
|
||||
protected $message = 'Circular dependency detected.';
|
||||
}
|
||||
class CircularDependencyException extends Exception {}
|
||||
|
@ -4,7 +4,4 @@ namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class InvalidVersionNumberException extends Exception
|
||||
{
|
||||
protected $message = 'The version number is an invalid semantic version.';
|
||||
}
|
||||
class InvalidVersionNumberException extends Exception {}
|
||||
|
@ -45,7 +45,7 @@ class ModController extends Controller
|
||||
|
||||
$this->authorize('view', $mod);
|
||||
|
||||
$latestVersion = $mod->versions->sortByDesc('version')->first();
|
||||
$latestVersion = $mod->versions->first();
|
||||
|
||||
return view('mod.show', compact(['mod', 'latestVersion']));
|
||||
}
|
||||
|
@ -31,8 +31,7 @@ class ModFilter
|
||||
{
|
||||
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
|
||||
->withTotalDownloads()
|
||||
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
|
||||
->whereHas('latestVersion');
|
||||
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -98,9 +97,10 @@ class ModFilter
|
||||
*/
|
||||
private function sptVersion(array $versions): Builder
|
||||
{
|
||||
return $this->builder->withWhereHas('latestVersion.sptVersion', function ($query) use ($versions) {
|
||||
$query->whereIn('version', $versions);
|
||||
$query->orderByDesc('version');
|
||||
return $this->builder->whereHas('latestVersion', function ($query) use ($versions) {
|
||||
$query->whereHas('sptVersion', function ($query) use ($versions) {
|
||||
$query->whereIn('version', $versions);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ use Illuminate\Support\Str;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Stevebauman\Purify\Facades\Purify;
|
||||
|
||||
class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
@ -42,6 +42,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
$this->bringFileContentLocal();
|
||||
$this->bringFileVersionLabelsLocal();
|
||||
$this->bringFileVersionContentLocal();
|
||||
$this->bringSptVersionTagsLocal();
|
||||
|
||||
// Begin to import the data into the permanent local database tables.
|
||||
$this->importUsers();
|
||||
@ -53,9 +54,8 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
// Ensure that we've disconnected from the Hub database, clearing temporary tables.
|
||||
DB::connection('mysql_hub')->disconnect();
|
||||
|
||||
// Re-sync search.
|
||||
Artisan::call('app:search-sync');
|
||||
|
||||
Artisan::call('app:resolve-versions');
|
||||
Artisan::call('cache:clear');
|
||||
}
|
||||
|
||||
@ -70,19 +70,24 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
avatarExtension VARCHAR(255),
|
||||
userID INT,
|
||||
fileHash VARCHAR(255)
|
||||
)');
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_user_avatar')
|
||||
->orderBy('avatarID')
|
||||
->chunk(200, function ($avatars) {
|
||||
$insertData = [];
|
||||
foreach ($avatars as $avatar) {
|
||||
DB::table('temp_user_avatar')->insert([
|
||||
$insertData[] = [
|
||||
'avatarID' => (int) $avatar->avatarID,
|
||||
'avatarExtension' => $avatar->avatarExtension,
|
||||
'userID' => (int) $avatar->userID,
|
||||
'fileHash' => $avatar->fileHash,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
if ($insertData) {
|
||||
DB::table('temp_user_avatar')->insert($insertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -93,17 +98,25 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
protected function bringFileAuthorsLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_author');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_author (fileID INT, userID INT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_author (
|
||||
fileID INT,
|
||||
userID INT
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_author')
|
||||
->orderBy('fileID')
|
||||
->chunk(200, function ($relationships) {
|
||||
$insertData = [];
|
||||
foreach ($relationships as $relationship) {
|
||||
DB::table('temp_file_author')->insert([
|
||||
$insertData[] = [
|
||||
'fileID' => (int) $relationship->fileID,
|
||||
'userID' => (int) $relationship->userID,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
if ($insertData) {
|
||||
DB::table('temp_file_author')->insert($insertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -114,18 +127,27 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
protected function bringFileOptionsLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_option_values (fileID INT, optionID INT, optionValue VARCHAR(255)) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_option_values (
|
||||
fileID INT,
|
||||
optionID INT,
|
||||
optionValue VARCHAR(255)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_option_value')
|
||||
->orderBy('fileID')
|
||||
->chunk(200, function ($options) {
|
||||
$insertData = [];
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_option_values')->insert([
|
||||
$insertData[] = [
|
||||
'fileID' => (int) $option->fileID,
|
||||
'optionID' => (int) $option->optionID,
|
||||
'optionValue' => $option->optionValue,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
if ($insertData) {
|
||||
DB::table('temp_file_option_values')->insert($insertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -136,19 +158,29 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
protected function bringFileContentLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_content');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_content (fileID INT, subject VARCHAR(255), teaser VARCHAR(255), message LONGTEXT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_content (
|
||||
fileID INT,
|
||||
subject VARCHAR(255),
|
||||
teaser VARCHAR(255),
|
||||
message LONGTEXT
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_content')
|
||||
->orderBy('fileID')
|
||||
->chunk(200, function ($contents) {
|
||||
$insertData = [];
|
||||
foreach ($contents as $content) {
|
||||
DB::table('temp_file_content')->insert([
|
||||
$insertData[] = [
|
||||
'fileID' => (int) $content->fileID,
|
||||
'subject' => $content->subject,
|
||||
'teaser' => $content->teaser,
|
||||
'message' => $content->message,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
if ($insertData) {
|
||||
DB::table('temp_file_content')->insert($insertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -159,18 +191,26 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
protected function bringFileVersionLabelsLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels (labelID INT, objectID INT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels (
|
||||
labelID INT,
|
||||
objectID INT
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_label_object')
|
||||
->where('objectTypeID', 387)
|
||||
->orderBy('labelID')
|
||||
->chunk(200, function ($options) {
|
||||
$insertData = [];
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_version_labels')->insert([
|
||||
$insertData[] = [
|
||||
'labelID' => (int) $option->labelID,
|
||||
'objectID' => (int) $option->objectID,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
if ($insertData) {
|
||||
DB::table('temp_file_version_labels')->insert($insertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -181,17 +221,54 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
protected function bringFileVersionContentLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_version_content (versionID INT, description TEXT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_version_content (
|
||||
versionID INT,
|
||||
description TEXT
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_version_content')
|
||||
->orderBy('versionID')
|
||||
->chunk(200, function ($options) {
|
||||
$insertData = [];
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_version_content')->insert([
|
||||
$insertData[] = [
|
||||
'versionID' => (int) $option->versionID,
|
||||
'description' => $option->description,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
if ($insertData) {
|
||||
DB::table('temp_file_version_content')->insert($insertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function bringSptVersionTagsLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_spt_version_tags');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_spt_version_tags (
|
||||
hub_id INT,
|
||||
version VARCHAR(255),
|
||||
color_class VARCHAR(255)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_label')
|
||||
->where('groupID', 1)
|
||||
->orderBy('labelID')
|
||||
->chunk(100, function (Collection $versions) {
|
||||
$insertData = [];
|
||||
foreach ($versions as $version) {
|
||||
$insertData[] = [
|
||||
'hub_id' => (int) $version->labelID,
|
||||
'version' => $version->label,
|
||||
'color_class' => $version->cssClassName,
|
||||
];
|
||||
}
|
||||
|
||||
if ($insertData) {
|
||||
DB::table('temp_spt_version_tags')->insert($insertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -531,40 +608,142 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
|
||||
/**
|
||||
* Import the SPT versions from the Hub database to the local database.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importSptVersions(): void
|
||||
{
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_label')
|
||||
->where('groupID', 1)
|
||||
->chunkById(100, function (Collection $versions) {
|
||||
$insertData = [];
|
||||
foreach ($versions as $version) {
|
||||
$insertData[] = [
|
||||
'hub_id' => (int) $version->labelID,
|
||||
'version' => $version->label,
|
||||
'color_class' => $this->translateColour($version->cssClassName),
|
||||
];
|
||||
}
|
||||
$domain = config('services.gitea.domain');
|
||||
$token = config('services.gitea.token');
|
||||
|
||||
if (! empty($insertData)) {
|
||||
DB::table('spt_versions')->upsert($insertData, ['hub_id'], ['version', 'color_class']);
|
||||
}
|
||||
}, 'labelID');
|
||||
if (empty($domain) || empty($token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = "{$domain}/api/v1/repos/SPT/Stable-releases/releases?draft=false&pre-release=false&token={$token}";
|
||||
|
||||
$response = json_decode(file_get_contents($url), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception('JSON Decode Error: '.json_last_error_msg());
|
||||
}
|
||||
|
||||
if (empty($response)) {
|
||||
throw new Exception('No version data found in the API response.');
|
||||
}
|
||||
|
||||
$latestVersion = $this->getLatestVersion($response);
|
||||
|
||||
$insertData = [];
|
||||
foreach ($response as $version) {
|
||||
$insertData[] = [
|
||||
'version' => $version['tag_name'],
|
||||
'link' => $version['html_url'],
|
||||
'color_class' => $this->detectVersionColor($version['tag_name'], $latestVersion),
|
||||
'created_at' => Carbon::parse($version['created_at'], 'UTC'),
|
||||
'updated_at' => Carbon::parse($version['created_at'], 'UTC'),
|
||||
];
|
||||
}
|
||||
|
||||
// Add a fake 0.0.0 version for outdated mods.
|
||||
$insertData[] = [
|
||||
'version' => '0.0.0',
|
||||
'link' => '',
|
||||
'color_class' => 'black',
|
||||
'created_at' => Carbon::now('UTC'),
|
||||
'updated_at' => Carbon::now('UTC'),
|
||||
];
|
||||
|
||||
// Upsert won't work here. Do it manually. :(
|
||||
foreach ($insertData as $data) {
|
||||
$existingVersion = SptVersion::where('version', $data['version'])->first();
|
||||
if ($existingVersion) {
|
||||
$existingVersion->update([
|
||||
'link' => $data['link'],
|
||||
'color_class' => $data['color_class'],
|
||||
'created_at' => $data['created_at'],
|
||||
'updated_at' => $data['updated_at'],
|
||||
]);
|
||||
} else {
|
||||
SptVersion::create($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the colour class from the Hub database to the local database.
|
||||
* Get the latest current version from the response data.
|
||||
*/
|
||||
protected function translateColour(string $colour = ''): string
|
||||
protected function getLatestVersion(array $versions): string
|
||||
{
|
||||
return match ($colour) {
|
||||
'green' => 'green',
|
||||
'slightly-outdated' => 'lime',
|
||||
'yellow' => 'yellow',
|
||||
'red' => 'red',
|
||||
default => 'gray',
|
||||
};
|
||||
$semanticVersions = array_map(
|
||||
fn ($version) => $this->extractSemanticVersion($version['tag_name']),
|
||||
$versions
|
||||
);
|
||||
|
||||
usort($semanticVersions, 'version_compare');
|
||||
|
||||
return end($semanticVersions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last semantic version from a string.
|
||||
* If the version has no patch number, return it as `~<major>.<minor>.0`.
|
||||
*/
|
||||
protected function extractSemanticVersion(string $versionString, bool $appendPatch = false): ?string
|
||||
{
|
||||
// Match both two-part and three-part semantic versions
|
||||
preg_match_all('/\b\d+\.\d+(?:\.\d+)?\b/', $versionString, $matches);
|
||||
|
||||
// Get the last version found, if any
|
||||
$version = end($matches[0]) ?: null;
|
||||
|
||||
if (! $appendPatch) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
// If version is two-part (e.g., "3.9"), add ".0" and prefix with "~"
|
||||
if ($version && preg_match('/^\d+\.\d+$/', $version)) {
|
||||
$version = '~'.$version.'.0';
|
||||
}
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the version string into a color class.
|
||||
*/
|
||||
protected function detectVersionColor(string $versionString, string $currentVersion): string
|
||||
{
|
||||
$version = $this->extractSemanticVersion($versionString);
|
||||
if (! $version) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
if ($version === '0.0.0') {
|
||||
return 'black';
|
||||
}
|
||||
|
||||
[$currentMajor, $currentMinor] = explode('.', $currentVersion);
|
||||
[$major, $minor] = explode('.', $version);
|
||||
|
||||
$currentMajor = (int) $currentMajor;
|
||||
$currentMinor = (int) $currentMinor;
|
||||
$major = (int) $major;
|
||||
$minor = (int) $minor;
|
||||
|
||||
if ($major == $currentMajor) {
|
||||
$difference = $currentMinor - $minor;
|
||||
|
||||
return match ($difference) {
|
||||
0 => 'green',
|
||||
1 => 'lime',
|
||||
2 => 'yellow',
|
||||
3 => 'red',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -748,17 +927,22 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch the version string using the labelID from the hub.
|
||||
$sptVersionTemp = DB::table('temp_spt_version_tags')->where('hub_id', $versionLabel->labelID)->value('version');
|
||||
$sptVersionConstraint = $this->extractSemanticVersion($sptVersionTemp, appendPatch: true) ?? '0.0.0';
|
||||
|
||||
$insertData[] = [
|
||||
'hub_id' => (int) $version->versionID,
|
||||
'mod_id' => $modId,
|
||||
'version' => $version->versionNumber,
|
||||
'version' => $this->extractSemanticVersion($version->versionNumber) ?? '0.0.0',
|
||||
'description' => $this->cleanHubContent($versionContent->description ?? ''),
|
||||
'link' => $version->downloadURL,
|
||||
'spt_version_id' => SptVersion::whereHubId($versionLabel->labelID)->value('id'),
|
||||
'spt_version_constraint' => $sptVersionConstraint,
|
||||
'resolved_spt_version_id' => null,
|
||||
'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '',
|
||||
'downloads' => max((int) $version->downloads, 0), // At least 0.
|
||||
'disabled' => (bool) $version->isDisabled,
|
||||
'published_at' => Carbon::parse($version->uploadTime, 'UTC'),
|
||||
'published_at' => $sptVersionConstraint === '0.0.0' ? null : Carbon::parse($version->uploadTime, 'UTC'),
|
||||
'created_at' => Carbon::parse($version->uploadTime, 'UTC'),
|
||||
'updated_at' => Carbon::parse($version->uploadTime, 'UTC'),
|
||||
];
|
||||
@ -770,7 +954,8 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
'version',
|
||||
'description',
|
||||
'link',
|
||||
'spt_version_id',
|
||||
'spt_version_constraint',
|
||||
'resolved_spt_version_id',
|
||||
'virus_total_link',
|
||||
'downloads',
|
||||
'published_at',
|
||||
@ -793,6 +978,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content');
|
||||
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels');
|
||||
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content');
|
||||
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_spt_version_tags');
|
||||
|
||||
// Close the connections. This should drop the temporary tables as well, but I like to be explicit.
|
||||
DB::connection('mysql_hub')->disconnect();
|
29
app/Jobs/ResolveDependenciesJob.php
Normal file
29
app/Jobs/ResolveDependenciesJob.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ModVersion;
|
||||
use App\Services\DependencyVersionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ResolveDependenciesJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Resolve the SPT versions for each of the mod versions.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$dependencyVersionService = new DependencyVersionService;
|
||||
|
||||
foreach (ModVersion::all() as $modVersion) {
|
||||
$dependencyVersionService->resolve($modVersion);
|
||||
}
|
||||
}
|
||||
}
|
29
app/Jobs/ResolveSptVersionsJob.php
Normal file
29
app/Jobs/ResolveSptVersionsJob.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ModVersion;
|
||||
use App\Services\SptVersionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ResolveSptVersionsJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Resolve the SPT versions for each of the mod versions.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$sptVersionService = new SptVersionService;
|
||||
|
||||
foreach (ModVersion::all() as $modVersion) {
|
||||
$sptVersionService->resolve($modVersion);
|
||||
}
|
||||
}
|
||||
}
|
@ -49,7 +49,10 @@ class Index extends Component
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
// TODO: This should be updated to only pull versions that have mods associated with them.
|
||||
// To do this, the ModVersion to SptVersion relationship needs to be converted to a many-to-many relationship. Ugh.
|
||||
$this->availableSptVersions = SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get();
|
||||
|
||||
$this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray();
|
||||
}
|
||||
|
||||
@ -75,7 +78,7 @@ class Index extends Component
|
||||
'order' => $this->order,
|
||||
'sptVersion' => $this->sptVersion,
|
||||
];
|
||||
$mods = (new ModFilter($filters))->apply()->paginate(24);
|
||||
$mods = (new ModFilter($filters))->apply()->paginate(16);
|
||||
|
||||
return view('livewire.mod.index', compact('mods'));
|
||||
}
|
||||
|
@ -58,9 +58,16 @@ class Mod extends Model
|
||||
/**
|
||||
* The relationship between a mod and its versions.
|
||||
*/
|
||||
public function versions(): HasMany
|
||||
public function versions(bool $resolvedOnly = true): HasMany
|
||||
{
|
||||
return $this->hasMany(ModVersion::class)->orderByDesc('version');
|
||||
$relation = $this->hasMany(ModVersion::class)
|
||||
->orderByDesc('version');
|
||||
|
||||
if ($resolvedOnly) {
|
||||
$relation->whereNotNull('resolved_spt_version_id');
|
||||
}
|
||||
|
||||
return $relation;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -77,10 +84,16 @@ class Mod extends Model
|
||||
/**
|
||||
* The relationship between a mod and its last updated version.
|
||||
*/
|
||||
public function lastUpdatedVersion(): HasOne
|
||||
public function lastUpdatedVersion(bool $resolvedOnly = true): HasOne
|
||||
{
|
||||
return $this->hasOne(ModVersion::class)
|
||||
$relation = $this->hasOne(ModVersion::class)
|
||||
->orderByDesc('updated_at');
|
||||
|
||||
if ($resolvedOnly) {
|
||||
$relation->whereNotNull('resolved_spt_version_id');
|
||||
}
|
||||
|
||||
return $relation;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -108,12 +121,18 @@ class Mod extends Model
|
||||
/**
|
||||
* The relationship to the latest mod version, dictated by the mod version number.
|
||||
*/
|
||||
public function latestVersion(): HasOne
|
||||
public function latestVersion(bool $resolvedOnly = true): HasOne
|
||||
{
|
||||
return $this->hasOne(ModVersion::class)
|
||||
$relation = $this->hasOne(ModVersion::class)
|
||||
->orderByDesc('version')
|
||||
->orderByDesc('updated_at')
|
||||
->take(1);
|
||||
|
||||
if ($resolvedOnly) {
|
||||
$relation->whereNotNull('resolved_spt_version_id');
|
||||
}
|
||||
|
||||
return $relation;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -26,7 +26,7 @@ class ModDependency extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* The relationship between a mod dependency and mod.
|
||||
* The relationship between the mod dependency and the mod that is depended on.
|
||||
*/
|
||||
public function dependencyMod(): BelongsTo
|
||||
{
|
||||
|
@ -39,9 +39,15 @@ class ModVersion extends Model
|
||||
/**
|
||||
* The relationship between a mod version and its dependencies.
|
||||
*/
|
||||
public function dependencies(): HasMany
|
||||
public function dependencies(bool $resolvedOnly = true): HasMany
|
||||
{
|
||||
return $this->hasMany(ModDependency::class);
|
||||
$relation = $this->hasMany(ModDependency::class);
|
||||
|
||||
if ($resolvedOnly) {
|
||||
$relation->whereNotNull('resolved_version_id');
|
||||
}
|
||||
|
||||
return $relation;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -49,6 +55,6 @@ class ModVersion extends Model
|
||||
*/
|
||||
public function sptVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SptVersion::class);
|
||||
return $this->belongsTo(SptVersion::class, 'resolved_spt_version_id');
|
||||
}
|
||||
}
|
||||
|
@ -36,22 +36,21 @@ class SptVersion extends Model
|
||||
}
|
||||
|
||||
try {
|
||||
$currentMinorVersion = $this->extractMinorVersion($this->version);
|
||||
$latestMinorVersion = $this->extractMinorVersion($latestVersion->version);
|
||||
[$currentMajor, $currentMinor, $currentPatch] = $this->extractVersionParts($this->version);
|
||||
[$latestMajor, $latestMinor, $latestPatch] = $this->extractVersionParts($latestVersion->version);
|
||||
} catch (InvalidVersionNumberException $e) {
|
||||
// Could not parse a semver version number.
|
||||
return false;
|
||||
}
|
||||
|
||||
return $currentMinorVersion === $latestMinorVersion;
|
||||
return $currentMajor == $latestMajor && $currentMinor === $latestMinor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the minor version from a full version string.
|
||||
* Extract the version components from a full version string.
|
||||
*
|
||||
* @throws InvalidVersionNumberException
|
||||
*/
|
||||
private function extractMinorVersion(string $version): int
|
||||
private function extractVersionParts(string $version): array
|
||||
{
|
||||
// Remove everything from the version string except the numbers and dots.
|
||||
$version = preg_replace('/[^0-9.]/', '', $version);
|
||||
@ -63,7 +62,10 @@ class SptVersion extends Model
|
||||
|
||||
$parts = explode('.', $version);
|
||||
|
||||
// Return the minor version part.
|
||||
return (int) $parts[1];
|
||||
return [
|
||||
(int) $parts[0],
|
||||
(int) $parts[1],
|
||||
(int) $parts[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -2,32 +2,50 @@
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Exceptions\CircularDependencyException;
|
||||
use App\Models\ModDependency;
|
||||
use App\Models\ModVersion;
|
||||
use App\Services\ModVersionService;
|
||||
use App\Services\DependencyVersionService;
|
||||
|
||||
class ModDependencyObserver
|
||||
{
|
||||
protected ModVersionService $modVersionService;
|
||||
protected DependencyVersionService $dependencyVersionService;
|
||||
|
||||
public function __construct(ModVersionService $modVersionService)
|
||||
public function __construct(DependencyVersionService $dependencyVersionService)
|
||||
{
|
||||
$this->modVersionService = $modVersionService;
|
||||
$this->dependencyVersionService = $dependencyVersionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ModDependency "saved" event.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
public function saved(ModDependency $modDependency): void
|
||||
{
|
||||
$modVersion = ModVersion::find($modDependency->mod_version_id);
|
||||
if ($modVersion) {
|
||||
$this->modVersionService->resolveDependencies($modVersion);
|
||||
}
|
||||
$this->resolveDependencyVersion($modDependency);
|
||||
}
|
||||
|
||||
public function deleted(ModDependency $modDependency): void
|
||||
/**
|
||||
* Resolve the ModDependency's dependencies.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
public function resolveDependencyVersion(ModDependency $modDependency): void
|
||||
{
|
||||
$modVersion = ModVersion::find($modDependency->mod_version_id);
|
||||
if ($modVersion) {
|
||||
$this->modVersionService->resolveDependencies($modVersion);
|
||||
$this->dependencyVersionService->resolve($modVersion);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ModDependency "deleted" event.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
public function deleted(ModDependency $modDependency): void
|
||||
{
|
||||
$this->resolveDependencyVersion($modDependency);
|
||||
}
|
||||
}
|
||||
|
@ -2,32 +2,57 @@
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Exceptions\CircularDependencyException;
|
||||
use App\Models\ModDependency;
|
||||
use App\Models\ModVersion;
|
||||
use App\Services\ModVersionService;
|
||||
use App\Services\DependencyVersionService;
|
||||
use App\Services\SptVersionService;
|
||||
|
||||
class ModVersionObserver
|
||||
{
|
||||
protected ModVersionService $modVersionService;
|
||||
protected DependencyVersionService $dependencyVersionService;
|
||||
|
||||
public function __construct(ModVersionService $modVersionService)
|
||||
{
|
||||
$this->modVersionService = $modVersionService;
|
||||
protected SptVersionService $sptVersionService;
|
||||
|
||||
public function __construct(
|
||||
DependencyVersionService $dependencyVersionService,
|
||||
SptVersionService $sptVersionService,
|
||||
) {
|
||||
$this->dependencyVersionService = $dependencyVersionService;
|
||||
$this->sptVersionService = $sptVersionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ModVersion "saved" event.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
public function saved(ModVersion $modVersion): void
|
||||
{
|
||||
$dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get();
|
||||
foreach ($dependencies as $dependency) {
|
||||
$this->modVersionService->resolveDependencies($dependency->modVersion);
|
||||
}
|
||||
$this->resolveDependencyVersion($modVersion);
|
||||
$this->sptVersionService->resolve($modVersion);
|
||||
}
|
||||
|
||||
public function deleted(ModVersion $modVersion): void
|
||||
/**
|
||||
* Resolve the ModVersion's dependencies.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
private function resolveDependencyVersion(ModVersion $modVersion): void
|
||||
{
|
||||
$dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get();
|
||||
foreach ($dependencies as $dependency) {
|
||||
$this->modVersionService->resolveDependencies($dependency->modVersion);
|
||||
$this->dependencyVersionService->resolve($dependency->modVersion);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ModVersion "deleted" event.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
public function deleted(ModVersion $modVersion): void
|
||||
{
|
||||
$this->resolveDependencyVersion($modVersion);
|
||||
}
|
||||
}
|
||||
|
44
app/Observers/SptVersionObserver.php
Normal file
44
app/Observers/SptVersionObserver.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\ModVersion;
|
||||
use App\Services\SptVersionService;
|
||||
|
||||
class SptVersionObserver
|
||||
{
|
||||
protected SptVersionService $sptVersionService;
|
||||
|
||||
public function __construct(SptVersionService $sptVersionService)
|
||||
{
|
||||
$this->sptVersionService = $sptVersionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SptVersion "saved" event.
|
||||
*/
|
||||
public function saved(): void
|
||||
{
|
||||
$this->resolveSptVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the SptVersion's dependencies.
|
||||
*/
|
||||
private function resolveSptVersion(): void
|
||||
{
|
||||
$modVersions = ModVersion::all();
|
||||
|
||||
foreach ($modVersions as $modVersion) {
|
||||
$this->sptVersionService->resolve($modVersion);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SptVersion "deleted" event.
|
||||
*/
|
||||
public function deleted(): void
|
||||
{
|
||||
$this->resolveSptVersion();
|
||||
}
|
||||
}
|
@ -4,9 +4,11 @@ namespace App\Providers;
|
||||
|
||||
use App\Models\ModDependency;
|
||||
use App\Models\ModVersion;
|
||||
use App\Models\SptVersion;
|
||||
use App\Models\User;
|
||||
use App\Observers\ModDependencyObserver;
|
||||
use App\Observers\ModVersionObserver;
|
||||
use App\Observers\SptVersionObserver;
|
||||
use App\Services\LatestSptVersionService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@ -36,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Register observers.
|
||||
ModVersion::observe(ModVersionObserver::class);
|
||||
ModDependency::observe(ModDependencyObserver::class);
|
||||
SptVersion::observe(SptVersionObserver::class);
|
||||
|
||||
// This gate determines who can access the Pulse dashboard.
|
||||
Gate::define('viewPulse', function (User $user) {
|
||||
|
119
app/Services/DependencyVersionService.php
Normal file
119
app/Services/DependencyVersionService.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\CircularDependencyException;
|
||||
use App\Models\ModDependency;
|
||||
use App\Models\ModVersion;
|
||||
use Composer\Semver\Semver;
|
||||
|
||||
class DependencyVersionService
|
||||
{
|
||||
/**
|
||||
* Keep track of visited versions to avoid resolving them again.
|
||||
*/
|
||||
protected array $visited = [];
|
||||
|
||||
/**
|
||||
* Keep track of the current path in the depth-first search.
|
||||
*/
|
||||
protected array $stack = [];
|
||||
|
||||
/**
|
||||
* Resolve dependencies for the given mod version.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
public function resolve(ModVersion $modVersion): array
|
||||
{
|
||||
$this->visited = [];
|
||||
$this->stack = [];
|
||||
|
||||
// Store the resolved versions for each dependency.
|
||||
$resolvedVersions = [];
|
||||
|
||||
// Start the recursive depth-first search to resolve dependencies.
|
||||
$this->processDependencies($modVersion, $resolvedVersions);
|
||||
|
||||
return $resolvedVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a depth-first search to resolve dependencies for the given mod version.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void
|
||||
{
|
||||
// Detect circular dependencies
|
||||
if (in_array($modVersion->id, $this->stack)) {
|
||||
throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}");
|
||||
}
|
||||
|
||||
// Skip already processed versions
|
||||
if (in_array($modVersion->id, $this->visited)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the current version
|
||||
$this->visited[] = $modVersion->id;
|
||||
$this->stack[] = $modVersion->id;
|
||||
|
||||
// Get the dependencies for the current mod version.
|
||||
$dependencies = $modVersion->dependencies(resolvedOnly: false)->get();
|
||||
|
||||
foreach ($dependencies as $dependency) {
|
||||
// Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency.
|
||||
$resolvedId = $this->resolveDependency($dependency);
|
||||
|
||||
// Update the resolved version ID for the dependency if it has changed.
|
||||
// Do it "quietly" to avoid triggering the observer again.
|
||||
if ($dependency->resolved_version_id !== $resolvedId) {
|
||||
$dependency->updateQuietly(['resolved_version_id' => $resolvedId]);
|
||||
}
|
||||
|
||||
// At this point, the dependency has been resolved (or not) and we can add it to the resolved versions to
|
||||
// avoid resolving it again in the future and to help with circular dependency detection.
|
||||
$resolvedVersions[$dependency->id] = $resolvedId ? ModVersion::find($resolvedId) : null;
|
||||
|
||||
// Recursively process the resolved dependency.
|
||||
if ($resolvedId) {
|
||||
$nextModVersion = ModVersion::find($resolvedId);
|
||||
if ($nextModVersion) {
|
||||
$this->processDependencies($nextModVersion, $resolvedVersions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the current version from the stack now that we have processed all its dependencies.
|
||||
array_pop($this->stack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency.
|
||||
*/
|
||||
protected function resolveDependency(ModDependency $dependency): ?int
|
||||
{
|
||||
$dependencyModVersions = $dependency->dependencyMod->versions(resolvedOnly: false);
|
||||
|
||||
// There are no mod versions for the dependency mod.
|
||||
if ($dependencyModVersions->doesntExist()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$availableVersions = $dependencyModVersions->pluck('id', 'version')->toArray();
|
||||
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint);
|
||||
|
||||
// There are no mod versions that satisfy the version constraint.
|
||||
if (empty($satisfyingVersions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort the satisfying versions in descending order using the version_compare function.
|
||||
usort($satisfyingVersions, 'version_compare');
|
||||
$satisfyingVersions = array_reverse($satisfyingVersions);
|
||||
|
||||
// Return the latest (highest version number) satisfying version.
|
||||
return $availableVersions[$satisfyingVersions[0]];
|
||||
}
|
||||
}
|
@ -4,6 +4,10 @@ namespace App\Services;
|
||||
|
||||
use App\Models\SptVersion;
|
||||
|
||||
/**
|
||||
* This class is responsible for fetching the latest SPT version. It's registered as a singleton in the service
|
||||
* container so that the latest version is only fetched once per request.
|
||||
*/
|
||||
class LatestSptVersionService
|
||||
{
|
||||
protected ?SptVersion $version = null;
|
||||
|
@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\CircularDependencyException;
|
||||
use App\Models\ModDependency;
|
||||
use App\Models\ModVersion;
|
||||
use Composer\Semver\Semver;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ModVersionService
|
||||
{
|
||||
protected array $visited = [];
|
||||
|
||||
protected array $stack = [];
|
||||
|
||||
/**
|
||||
* Resolve dependencies for the given mod version.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
public function resolveDependencies(ModVersion $modVersion): array
|
||||
{
|
||||
$resolvedVersions = [];
|
||||
$this->visited = [];
|
||||
$this->stack = [];
|
||||
|
||||
$this->processDependencies($modVersion, $resolvedVersions);
|
||||
|
||||
return $resolvedVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a depth-first search to resolve dependencies for the given mod version.
|
||||
*
|
||||
* @throws CircularDependencyException
|
||||
*/
|
||||
protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void
|
||||
{
|
||||
if (in_array($modVersion->id, $this->stack)) {
|
||||
throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}");
|
||||
}
|
||||
|
||||
if (in_array($modVersion->id, $this->visited)) {
|
||||
return; // Skip already processed versions
|
||||
}
|
||||
|
||||
$this->visited[] = $modVersion->id;
|
||||
$this->stack[] = $modVersion->id;
|
||||
|
||||
/** @var Collection|ModDependency[] $dependencies */
|
||||
$dependencies = $this->getDependencies($modVersion);
|
||||
|
||||
foreach ($dependencies as $dependency) {
|
||||
$resolvedVersionId = $this->resolveVersionIdForDependency($dependency);
|
||||
|
||||
if ($dependency->resolved_version_id !== $resolvedVersionId) {
|
||||
$dependency->updateQuietly(['resolved_version_id' => $resolvedVersionId]);
|
||||
}
|
||||
|
||||
$resolvedVersions[$dependency->id] = $resolvedVersionId ? ModVersion::find($resolvedVersionId) : null;
|
||||
|
||||
if ($resolvedVersionId) {
|
||||
$nextModVersion = ModVersion::find($resolvedVersionId);
|
||||
if ($nextModVersion) {
|
||||
$this->processDependencies($nextModVersion, $resolvedVersions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
array_pop($this->stack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dependencies for the given mod version.
|
||||
*/
|
||||
protected function getDependencies(ModVersion $modVersion): Collection
|
||||
{
|
||||
return $modVersion->dependencies()->with(['dependencyMod.versions'])->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the latest version ID that satisfies the version constraint on given dependency.
|
||||
*/
|
||||
protected function resolveVersionIdForDependency(ModDependency $dependency): ?int
|
||||
{
|
||||
$mod = $dependency->dependencyMod;
|
||||
|
||||
if (! $mod || $mod->versions->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$availableVersions = $mod->versions->pluck('id', 'version')->toArray();
|
||||
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint);
|
||||
|
||||
// Versions are sorted in descending order by default. Take the first key (the latest version) using `reset()`.
|
||||
return $satisfyingVersions ? $availableVersions[reset($satisfyingVersions)] : null;
|
||||
}
|
||||
}
|
42
app/Services/SptVersionService.php
Normal file
42
app/Services/SptVersionService.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ModVersion;
|
||||
use App\Models\SptVersion;
|
||||
use Composer\Semver\Semver;
|
||||
|
||||
class SptVersionService
|
||||
{
|
||||
/**
|
||||
* Resolve dependencies for the given mod version.
|
||||
*/
|
||||
public function resolve(ModVersion $modVersion): void
|
||||
{
|
||||
$modVersion->resolved_spt_version_id = $this->satisfyconstraint($modVersion);
|
||||
$modVersion->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Satisfies the version constraint of a given ModVersion. Returns the ID of the satisfying SptVersion.
|
||||
*/
|
||||
private function satisfyConstraint(ModVersion $modVersion): ?int
|
||||
{
|
||||
$availableVersions = SptVersion::query()
|
||||
->orderBy('version', 'desc')
|
||||
->pluck('id', 'version')
|
||||
->toArray();
|
||||
|
||||
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $modVersion->spt_version_constraint);
|
||||
if (empty($satisfyingVersions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the satisfying versions are sorted in descending order to get the latest version
|
||||
usort($satisfyingVersions, 'version_compare');
|
||||
$satisfyingVersions = array_reverse($satisfyingVersions);
|
||||
|
||||
// Return the ID of the latest satisfying version
|
||||
return $availableVersions[$satisfyingVersions[0]];
|
||||
}
|
||||
}
|
@ -30,7 +30,6 @@ class ModListSection extends Component
|
||||
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
|
||||
->withTotalDownloads()
|
||||
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
|
||||
->whereHas('latestVersion')
|
||||
->where('featured', true)
|
||||
->latest()
|
||||
->limit(6)
|
||||
@ -44,7 +43,6 @@ class ModListSection extends Component
|
||||
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
|
||||
->withTotalDownloads()
|
||||
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
|
||||
->whereHas('latestVersion')
|
||||
->latest()
|
||||
->limit(6)
|
||||
->get();
|
||||
@ -57,7 +55,6 @@ class ModListSection extends Component
|
||||
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
|
||||
->withTotalDownloads()
|
||||
->with(['lastUpdatedVersion', 'lastUpdatedVersion.sptVersion', 'users:id,name'])
|
||||
->whereHas('lastUpdatedVersion')
|
||||
->orderByDesc(
|
||||
ModVersion::select('updated_at')
|
||||
->whereColumn('mod_id', 'mods.id')
|
||||
|
@ -35,4 +35,9 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'gitea' => [
|
||||
'domain' => env('GITEA_DOMAIN', ''),
|
||||
'token' => env('GITEA_TOKEN', ''),
|
||||
],
|
||||
|
||||
];
|
||||
|
@ -17,9 +17,19 @@ class ModDependencyFactory extends Factory
|
||||
return [
|
||||
'mod_version_id' => ModVersion::factory(),
|
||||
'dependency_mod_id' => Mod::factory(),
|
||||
'version_constraint' => '^'.$this->faker->numerify('#.#.#'),
|
||||
'version_constraint' => fake()->numerify($this->generateVersionConstraint()),
|
||||
'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
|
||||
'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* This method generates a random version constraint from a predefined set of options.
|
||||
*/
|
||||
private function generateVersionConstraint(): string
|
||||
{
|
||||
$versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#', '>=1.2.# <2.#.#'];
|
||||
|
||||
return $versionConstraints[array_rand($versionConstraints)];
|
||||
}
|
||||
}
|
||||
|
@ -14,12 +14,15 @@ class ModVersionFactory extends Factory
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$constraint = fake()->numerify($this->generateVersionConstraint());
|
||||
|
||||
return [
|
||||
'mod_id' => Mod::factory(),
|
||||
'version' => fake()->numerify('#.#.#'),
|
||||
'description' => fake()->text(),
|
||||
'link' => fake()->url(),
|
||||
'spt_version_id' => SptVersion::factory(),
|
||||
'spt_version_constraint' => $constraint,
|
||||
'resolved_spt_version_id' => null,
|
||||
'virus_total_link' => fake()->url(),
|
||||
'downloads' => fake()->randomNumber(),
|
||||
'published_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
|
||||
@ -28,6 +31,31 @@ class ModVersionFactory extends Factory
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* This method generates a random version constraint from a predefined set of options.
|
||||
*/
|
||||
private function generateVersionConstraint(): string
|
||||
{
|
||||
$versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#'];
|
||||
|
||||
return $versionConstraints[array_rand($versionConstraints)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the mod version should have a resolved SPT version.
|
||||
*/
|
||||
public function sptVersionResolved(): static
|
||||
{
|
||||
$constraint = fake()->numerify('#.#.#');
|
||||
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'spt_version_constraint' => $constraint,
|
||||
'resolved_spt_version_id' => SptVersion::factory()->create([
|
||||
'version' => $constraint,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the mod version should be disabled.
|
||||
*/
|
||||
|
@ -13,8 +13,9 @@ class SptVersionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'version' => $this->faker->numerify('SPT 1.#.#'),
|
||||
'version' => $this->faker->numerify('#.#.#'),
|
||||
'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']),
|
||||
'link' => $this->faker->url,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
|
@ -15,6 +15,7 @@ return new class extends Migration
|
||||
->default(null)
|
||||
->unique();
|
||||
$table->string('version');
|
||||
$table->string('link');
|
||||
$table->string('color_class');
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
|
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Mod;
|
||||
use App\Models\SptVersion;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@ -23,9 +22,9 @@ return new class extends Migration
|
||||
$table->string('version');
|
||||
$table->longText('description');
|
||||
$table->string('link');
|
||||
$table->foreignIdFor(SptVersion::class)
|
||||
$table->string('spt_version_constraint');
|
||||
$table->foreignId('resolved_spt_version_id')
|
||||
->nullable()
|
||||
->default(null)
|
||||
->constrained('spt_versions')
|
||||
->nullOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
|
@ -18,7 +18,7 @@ return new class extends Migration
|
||||
->constrained('mods')
|
||||
->cascadeOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
$table->string('version_constraint'); // e.g., ^1.0.1
|
||||
$table->string('version_constraint');
|
||||
$table->foreignId('resolved_version_id')
|
||||
->nullable()
|
||||
->constrained('mod_versions')
|
||||
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
@ -2,7 +2,6 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Exceptions\CircularDependencyException;
|
||||
use App\Models\License;
|
||||
use App\Models\Mod;
|
||||
use App\Models\ModDependency;
|
||||
@ -20,7 +19,7 @@ class DatabaseSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
// Create a few SPT versions.
|
||||
$spt_versions = SptVersion::factory(10)->create();
|
||||
$spt_versions = SptVersion::factory(30)->create();
|
||||
|
||||
// Create some code licenses.
|
||||
$licenses = License::factory(10)->create();
|
||||
@ -39,40 +38,26 @@ class DatabaseSeeder extends Seeder
|
||||
// Add 100 users.
|
||||
$users = User::factory(100)->create();
|
||||
|
||||
// Add 200 mods, assigning them to the users we just created.
|
||||
// Add 300 mods, assigning them to the users we just created.
|
||||
$allUsers = $users->merge([$administrator, $moderator]);
|
||||
$mods = Mod::factory(200)->recycle([$licenses])->create();
|
||||
$mods = Mod::factory(300)->recycle([$licenses])->create();
|
||||
foreach ($mods as $mod) {
|
||||
$userIds = $allUsers->random(rand(1, 3))->pluck('id')->toArray();
|
||||
$mod->users()->attach($userIds);
|
||||
}
|
||||
|
||||
// Add 1000 mod versions, assigning them to the mods we just created.
|
||||
$modVersions = ModVersion::factory(1000)->recycle([$mods, $spt_versions])->create();
|
||||
// Add 3000 mod versions, assigning them to the mods we just created.
|
||||
$modVersions = ModVersion::factory(3000)->recycle([$mods, $spt_versions])->create();
|
||||
|
||||
// Add ModDependencies to a subset of ModVersions.
|
||||
foreach ($modVersions as $modVersion) {
|
||||
$hasDependencies = rand(0, 100) < 30; // 30% chance to have dependencies
|
||||
if ($hasDependencies) {
|
||||
$numDependencies = rand(1, 3); // 1 to 3 dependencies
|
||||
$dependencyMods = $mods->random($numDependencies);
|
||||
$dependencyMods = $mods->random(rand(1, 3)); // 1 to 3 dependencies
|
||||
foreach ($dependencyMods as $dependencyMod) {
|
||||
try {
|
||||
ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create([
|
||||
'version_constraint' => $this->generateVersionConstraint(),
|
||||
]);
|
||||
} catch (CircularDependencyException $e) {
|
||||
continue;
|
||||
}
|
||||
ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateVersionConstraint(): string
|
||||
{
|
||||
$versionConstraints = ['*', '^1.0.0', '>=2.0.0', '~1.1.0', '>=1.2.0 <2.0.0'];
|
||||
|
||||
return $versionConstraints[array_rand($versionConstraints)];
|
||||
}
|
||||
}
|
||||
|
2
package-lock.json
generated
2
package-lock.json
generated
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "html",
|
||||
"name": "forge",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
12
phpstan.neon
12
phpstan.neon
@ -1,9 +1,13 @@
|
||||
includes:
|
||||
- ./vendor/larastan/larastan/extension.neon
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- app/
|
||||
- app
|
||||
- bootstrap
|
||||
- config
|
||||
- database
|
||||
- lang
|
||||
- routes
|
||||
excludePaths:
|
||||
analyseAndScan:
|
||||
- tests/
|
||||
level: 4
|
||||
- tests/**/*
|
||||
|
@ -1,4 +1,4 @@
|
||||
@props(['mods, versionScope, title'])
|
||||
@props(['mods', 'versionScope', 'title'])
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 pt-16 sm:px-6 lg:px-8">
|
||||
{{--
|
||||
|
@ -156,7 +156,7 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center">
|
||||
<div class="text-center text-gray-700 dark:text-gray-300">
|
||||
<p>{{ __('There were no mods found with those filters applied. ') }}</p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mx-auto">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0 0 12.016 15a4.486 4.486 0 0 0-3.198 1.318M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
|
||||
|
@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Console\Commands\ImportHub;
|
||||
use App\Console\Commands\ImportHubCommand;
|
||||
use App\Console\Commands\ResolveVersionsCommand;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command(ImportHub::class)->hourly();
|
||||
Schedule::command(ImportHubCommand::class)->hourly();
|
||||
Schedule::command(ResolveVersionsCommand::class)->hourlyAt(30);
|
||||
|
||||
Schedule::command('horizon:snapshot')->everyFiveMinutes();
|
||||
|
@ -13,13 +13,13 @@ it('resolves mod version dependency when mod version is created', function () {
|
||||
$modB = Mod::factory()->create(['name' => 'Mod B']);
|
||||
|
||||
// Create versions for Mod B
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
|
||||
ModVersion::factory()->recycle($modB)->create(['version' => '1.0.0']);
|
||||
ModVersion::factory()->recycle($modB)->create(['version' => '1.1.0']);
|
||||
ModVersion::factory()->recycle($modB)->create(['version' => '1.1.1']);
|
||||
ModVersion::factory()->recycle($modB)->create(['version' => '2.0.0']);
|
||||
|
||||
// Create versions for Mod A that depends on Mod B
|
||||
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
|
||||
$modAv1 = ModVersion::factory()->recycle($modA)->create(['version' => '1.0.0']);
|
||||
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
|
||||
'version_constraint' => '^1.0.0',
|
||||
]);
|
||||
@ -74,7 +74,7 @@ it('resolves mod version dependency when mod version is deleted', function () {
|
||||
$modDependency->refresh();
|
||||
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
|
||||
|
||||
// Update the mod B version
|
||||
// Delete the mod B version
|
||||
$modBv3->delete();
|
||||
|
||||
$modDependency->refresh();
|
||||
@ -155,6 +155,34 @@ it('resolves mod version dependency with complex semantic version constraint', f
|
||||
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
|
||||
});
|
||||
|
||||
it('resolves previously unresolved mod version dependency after semantic version constraint is updated', function () {
|
||||
$modA = Mod::factory()->create(['name' => 'Mod A']);
|
||||
$modB = Mod::factory()->create(['name' => 'Mod B']);
|
||||
|
||||
// Create versions for Mod B
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
|
||||
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.1']);
|
||||
|
||||
// Create version for Mod A that depends on Mod B, but no version satisfies the constraint.
|
||||
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
|
||||
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
|
||||
'version_constraint' => '^3.0.0',
|
||||
]);
|
||||
|
||||
$modDependency->refresh();
|
||||
expect($modDependency->resolved_version_id)->toBeNull();
|
||||
|
||||
// Update the dependency version constraint
|
||||
$modDependency->update(['version_constraint' => '^2.0.0']);
|
||||
|
||||
$modDependency->refresh();
|
||||
expect($modDependency->resolved_version_id)->not->toBeNull();
|
||||
expect($modDependency->resolvedVersion->version)->toBe('2.0.1');
|
||||
});
|
||||
|
||||
it('resolves null when no mod versions are available', function () {
|
||||
$modA = Mod::factory()->create(['name' => 'Mod A']);
|
||||
$modB = Mod::factory()->create(['name' => 'Mod B']);
|
||||
|
@ -6,11 +6,28 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows the latest version on the mod detail page', function () {
|
||||
it('can retrieve all unresolved versions', function () {
|
||||
// Create a mod instance
|
||||
$mod = Mod::factory()->create();
|
||||
ModVersion::factory(5)->recycle($mod)->create();
|
||||
|
||||
// Create 5 mod versions with specified versions
|
||||
ModVersion::all()->each(function (ModVersion $modVersion) {
|
||||
$modVersion->resolved_spt_version_id = null;
|
||||
$modVersion->saveQuietly();
|
||||
});
|
||||
|
||||
$unresolvedMix = $mod->versions(resolvedOnly: false);
|
||||
|
||||
$unresolvedMix->each(function (ModVersion $modVersion) {
|
||||
expect($modVersion)->toBeInstanceOf(ModVersion::class)
|
||||
->and($modVersion->resolved_spt_version_id)->toBeNull();
|
||||
});
|
||||
|
||||
expect($unresolvedMix->count())->toBe(5)
|
||||
->and($mod->versions->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('shows the latest version on the mod detail page', function () {
|
||||
$versions = [
|
||||
'1.0.0',
|
||||
'1.1.0',
|
||||
@ -18,21 +35,16 @@ it('shows the latest version on the mod detail page', function () {
|
||||
'2.0.0',
|
||||
'2.1.0',
|
||||
];
|
||||
|
||||
// get the highest version in the array
|
||||
$latestVersion = max($versions);
|
||||
|
||||
$mod = Mod::factory()->create();
|
||||
foreach ($versions as $version) {
|
||||
ModVersion::factory()->create([
|
||||
'mod_id' => $mod->id,
|
||||
'version' => $version,
|
||||
]);
|
||||
ModVersion::factory()->sptVersionResolved()->recycle($mod)->create(['version' => $version]);
|
||||
}
|
||||
|
||||
// Make a request to the mod's detail URL
|
||||
$response = $this->get($mod->detailUrl());
|
||||
|
||||
$this->assertEquals('2.1.0', $latestVersion);
|
||||
expect($latestVersion)->toBe('2.1.0');
|
||||
|
||||
// Assert the latest version is next to the mod's name
|
||||
$response->assertSeeInOrder(explode(' ', "$mod->name $latestVersion"));
|
||||
|
@ -1,11 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ModVersion;
|
||||
use App\Models\SptVersion;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('resolves spt version when mod version is created', function () {
|
||||
SptVersion::factory()->create(['version' => '1.0.0']);
|
||||
SptVersion::factory()->create(['version' => '1.1.0']);
|
||||
SptVersion::factory()->create(['version' => '1.1.1']);
|
||||
SptVersion::factory()->create(['version' => '1.2.0']);
|
||||
|
||||
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
|
||||
|
||||
$modVersion->refresh();
|
||||
|
||||
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
|
||||
expect($modVersion->sptVersion->version)->toBe('1.1.1');
|
||||
});
|
||||
|
||||
it('resolves spt version when constraint is updated', function () {
|
||||
SptVersion::factory()->create(['version' => '1.0.0']);
|
||||
SptVersion::factory()->create(['version' => '1.1.0']);
|
||||
SptVersion::factory()->create(['version' => '1.1.1']);
|
||||
SptVersion::factory()->create(['version' => '1.2.0']);
|
||||
|
||||
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
|
||||
|
||||
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
|
||||
expect($modVersion->sptVersion->version)->toBe('1.1.1');
|
||||
|
||||
$modVersion->spt_version_constraint = '~1.2.0';
|
||||
$modVersion->save();
|
||||
|
||||
$modVersion->refresh();
|
||||
|
||||
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
|
||||
expect($modVersion->sptVersion->version)->toBe('1.2.0');
|
||||
});
|
||||
|
||||
it('resolves spt version when spt version is created', function () {
|
||||
SptVersion::factory()->create(['version' => '1.0.0']);
|
||||
SptVersion::factory()->create(['version' => '1.1.0']);
|
||||
SptVersion::factory()->create(['version' => '1.1.1']);
|
||||
SptVersion::factory()->create(['version' => '1.2.0']);
|
||||
|
||||
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
|
||||
|
||||
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
|
||||
expect($modVersion->sptVersion->version)->toBe('1.1.1');
|
||||
|
||||
SptVersion::factory()->create(['version' => '1.1.2']);
|
||||
|
||||
$modVersion->refresh();
|
||||
|
||||
expect($modVersion->sptVersion->version)->toBe('1.1.2');
|
||||
});
|
||||
|
||||
it('resolves spt version when spt version is deleted', function () {
|
||||
SptVersion::factory()->create(['version' => '1.0.0']);
|
||||
SptVersion::factory()->create(['version' => '1.1.0']);
|
||||
SptVersion::factory()->create(['version' => '1.1.1']);
|
||||
$sptVersion = SptVersion::factory()->create(['version' => '1.1.2']);
|
||||
|
||||
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
|
||||
|
||||
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
|
||||
expect($modVersion->sptVersion->version)->toBe('1.1.2');
|
||||
|
||||
$sptVersion->delete();
|
||||
$modVersion->refresh();
|
||||
|
||||
expect($modVersion->sptVersion->version)->toBe('1.1.1');
|
||||
});
|
||||
|
||||
it('includes only published mod versions', function () {
|
||||
$publishedMod = ModVersion::factory()->create([
|
||||
'published_at' => Carbon::now()->subDay(),
|
||||
|
Loading…
x
Reference in New Issue
Block a user