From 1783a683ed5511ce071fb0ae171da90a656cb406 Mon Sep 17 00:00:00 2001 From: Refringe Date: Thu, 29 Aug 2024 15:46:10 -0400 Subject: [PATCH] Semvar & Automatic Resolution - Remix - Updated the SptVersion and ModVersion dependancies to resolve *all* compatible versions and introduced new relationships to pull just the latest compatible version. Had to rewrite a *bunch*, but it should be much more capable now. It can be expensive to resolve these properties when iterated over, so *make sure they're eager loaded using the `with` method when you're building the queries*. - Updated the mod listing Livewire component to save the filter options within the PHP session instead of in browser local storage. *Much* cleaner. - Removed caching from homepage queries to see how they preform on production. Will add back later. - Updated ModVersion factory to create SptVersions if there are none specified. - Probably lots of other changes too... I need to make smaller commits. :( --- app/Http/Controllers/ModController.php | 12 +- app/Http/Filters/ModFilter.php | 15 +- app/Jobs/ImportHubDataJob.php | 5 +- app/Livewire/Mod/Index.php | 26 +- app/Models/Mod.php | 67 ++-- app/Models/ModDependency.php | 17 +- app/Models/ModResolvedDependency.php | 33 ++ app/Models/ModVersion.php | 54 ++- app/Models/SptVersion.php | 14 +- app/Observers/ModDependencyObserver.php | 23 +- app/Observers/ModObserver.php | 27 ++ app/Observers/ModVersionObserver.php | 23 +- app/Providers/AppServiceProvider.php | 3 + app/Services/DependencyVersionService.php | 114 ++----- app/Services/SptVersionService.php | 16 +- app/View/Components/ModListSection.php | 70 ++-- database/factories/ModDependencyFactory.php | 14 +- database/factories/ModVersionFactory.php | 51 ++- .../2024_05_15_022710_create_mods_table.php | 4 +- ...05_15_023705_create_mod_versions_table.php | 9 +- ...0_create_mod_version_spt_version_table.php | 26 ++ ...4_07_25_161219_create_mod_dependencies.php | 19 +- ...61219_create_mod_resolved_dependencies.php | 26 ++ resources/js/app.js | 9 - resources/views/components/mod-card.blade.php | 4 +- resources/views/livewire/mod/index.blade.php | 45 +-- resources/views/mod/show.blade.php | 42 ++- tests/Feature/Mod/ModDependencyTest.php | 312 ++++++++++++++++++ tests/Feature/Mod/ModFilterTest.php | 161 +++++++++ tests/Feature/Mod/ModTest.php | 52 +++ tests/Feature/{ => Mod}/ModVersionTest.php | 55 ++- tests/Feature/ModDependencyTest.php | 266 --------------- tests/Feature/ModTest.php | 54 --- tests/Feature/StressTest.php | 9 - 34 files changed, 957 insertions(+), 720 deletions(-) create mode 100644 app/Models/ModResolvedDependency.php create mode 100644 app/Observers/ModObserver.php create mode 100644 database/migrations/2024_05_15_023710_create_mod_version_spt_version_table.php create mode 100644 database/migrations/2024_07_25_161219_create_mod_resolved_dependencies.php create mode 100644 tests/Feature/Mod/ModDependencyTest.php create mode 100644 tests/Feature/Mod/ModFilterTest.php create mode 100644 tests/Feature/Mod/ModTest.php rename tests/Feature/{ => Mod}/ModVersionTest.php (64%) delete mode 100644 tests/Feature/ModDependencyTest.php delete mode 100644 tests/Feature/ModTest.php delete mode 100644 tests/Feature/StressTest.php diff --git a/app/Http/Controllers/ModController.php b/app/Http/Controllers/ModController.php index 777f095..f019ad4 100644 --- a/app/Http/Controllers/ModController.php +++ b/app/Http/Controllers/ModController.php @@ -30,13 +30,13 @@ class ModController extends Controller $mod = Mod::withTotalDownloads() ->with([ 'versions', - 'versions.sptVersion', - 'versions.dependencies', - 'versions.dependencies.resolvedVersion', - 'versions.dependencies.resolvedVersion.mod', + 'versions.latestSptVersion:id,version,color_class', + 'versions.latestResolvedDependencies', + 'versions.latestResolvedDependencies.mod:id,name,slug', 'users:id,name', 'license:id,name,link', ]) + ->whereHas('latestVersion') ->findOrFail($modId); if ($mod->slug !== $slug) { @@ -45,9 +45,7 @@ class ModController extends Controller $this->authorize('view', $mod); - $latestVersion = $mod->versions->first(); - - return view('mod.show', compact(['mod', 'latestVersion'])); + return view('mod.show', compact(['mod'])); } public function update(ModRequest $request, Mod $mod) diff --git a/app/Http/Filters/ModFilter.php b/app/Http/Filters/ModFilter.php index a162a94..561ef24 100644 --- a/app/Http/Filters/ModFilter.php +++ b/app/Http/Filters/ModFilter.php @@ -31,7 +31,12 @@ class ModFilter { return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at']) ->withTotalDownloads() - ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']); + ->with([ + 'users:id,name', + 'latestVersion' => function ($query) { + $query->with('latestSptVersion:id,version,color_class'); + }, + ]); } /** @@ -95,12 +100,10 @@ class ModFilter /** * Filter the results to a specific SPT version. */ - private function sptVersion(array $versions): Builder + private function sptVersions(array $versions): Builder { - return $this->builder->whereHas('latestVersion', function ($query) use ($versions) { - $query->whereHas('sptVersion', function ($query) use ($versions) { - $query->whereIn('version', $versions); - }); + return $this->builder->whereHas('latestVersion.sptVersions', function ($query) use ($versions) { + $query->whereIn('spt_versions.version', $versions); }); } } diff --git a/app/Jobs/ImportHubDataJob.php b/app/Jobs/ImportHubDataJob.php index f6b9dc6..2dff469 100644 --- a/app/Jobs/ImportHubDataJob.php +++ b/app/Jobs/ImportHubDataJob.php @@ -608,8 +608,6 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Import the SPT versions from the Hub database to the local database. - * - * @throws Exception */ protected function importSptVersions(): void { @@ -938,7 +936,6 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue 'description' => $this->cleanHubContent($versionContent->description ?? ''), 'link' => $version->downloadURL, '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, @@ -955,9 +952,9 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue 'description', 'link', 'spt_version_constraint', - 'resolved_spt_version_id', 'virus_total_link', 'downloads', + 'disabled', 'published_at', 'created_at', 'updated_at', diff --git a/app/Livewire/Mod/Index.php b/app/Livewire/Mod/Index.php index 92d1fbb..3cc2c34 100644 --- a/app/Livewire/Mod/Index.php +++ b/app/Livewire/Mod/Index.php @@ -6,7 +6,9 @@ use App\Http\Filters\ModFilter; use App\Models\SptVersion; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Cache; use Livewire\Attributes\Computed; +use Livewire\Attributes\Session; use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; @@ -19,24 +21,28 @@ class Index extends Component * The search query value. */ #[Url] + #[Session] public string $query = ''; /** * The sort order value. */ #[Url] + #[Session] public string $order = 'created'; /** - * The SPT version filter value. + * The SPT versions filter value. */ #[Url] - public array $sptVersion = []; + #[Session] + public array $sptVersions = []; /** * The featured filter value. */ #[Url] + #[Session] public string $featured = 'include'; /** @@ -49,11 +55,13 @@ 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(); + // TODO: This should ideally be updated to only pull SPT versions that have mods associated with them so that no + // empty options are shown in the listing filter. + $this->availableSptVersions = Cache::remember('availableSptVersions', 60 * 60, function () { + return SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get(); + }); - $this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray(); + $this->sptVersions = $this->sptVersions ?? $this->getLatestMinorVersions()->pluck('version')->toArray(); } /** @@ -76,7 +84,7 @@ class Index extends Component 'query' => $this->query, 'featured' => $this->featured, 'order' => $this->order, - 'sptVersion' => $this->sptVersion, + 'sptVersions' => $this->sptVersions, ]; $mods = (new ModFilter($filters))->apply()->paginate(16); @@ -89,7 +97,7 @@ class Index extends Component public function resetFilters(): void { $this->query = ''; - $this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray(); + $this->sptVersions = $this->getLatestMinorVersions()->pluck('version')->toArray(); $this->featured = 'include'; // Clear local storage @@ -109,7 +117,7 @@ class Index extends Component if ($this->featured !== 'include') { $count++; } - $count += count($this->sptVersion); + $count += count($this->sptVersions); return $count; } diff --git a/app/Models/Mod.php b/app/Models/Mod.php index 57596b0..5261c14 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -58,16 +58,11 @@ class Mod extends Model /** * The relationship between a mod and its versions. */ - public function versions(bool $resolvedOnly = true): HasMany + public function versions(): HasMany { - $relation = $this->hasMany(ModVersion::class) + return $this->hasMany(ModVersion::class) + ->whereHas('sptVersions') ->orderByDesc('version'); - - if ($resolvedOnly) { - $relation->whereNotNull('resolved_spt_version_id'); - } - - return $relation; } /** @@ -84,16 +79,11 @@ class Mod extends Model /** * The relationship between a mod and its last updated version. */ - public function lastUpdatedVersion(bool $resolvedOnly = true): HasOne + public function lastUpdatedVersion(): HasOne { - $relation = $this->hasOne(ModVersion::class) + return $this->hasOne(ModVersion::class) + ->whereHas('sptVersions') ->orderByDesc('updated_at'); - - if ($resolvedOnly) { - $relation->whereNotNull('resolved_spt_version_id'); - } - - return $relation; } /** @@ -101,10 +91,8 @@ class Mod extends Model */ public function toSearchableArray(): array { - $latestVersion = $this->latestVersion()->with('sptVersion')->first(); - return [ - 'id' => (int) $this->id, + 'id' => $this->id, 'name' => $this->name, 'slug' => $this->slug, 'description' => $this->description, @@ -113,26 +101,21 @@ class Mod extends Model 'created_at' => strtotime($this->created_at), 'updated_at' => strtotime($this->updated_at), 'published_at' => strtotime($this->published_at), - 'latestVersion' => $latestVersion?->sptVersion->version, - 'latestVersionColorClass' => $latestVersion?->sptVersion->color_class, + 'latestVersion' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->version_formatted, + 'latestVersionColorClass' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->color_class, ]; } /** * The relationship to the latest mod version, dictated by the mod version number. */ - public function latestVersion(bool $resolvedOnly = true): HasOne + public function latestVersion(): HasOne { - $relation = $this->hasOne(ModVersion::class) + return $this->hasOne(ModVersion::class) + ->whereHas('sptVersions') ->orderByDesc('version') ->orderByDesc('updated_at') ->take(1); - - if ($resolvedOnly) { - $relation->whereNotNull('resolved_spt_version_id'); - } - - return $relation; } /** @@ -140,7 +123,31 @@ class Mod extends Model */ public function shouldBeSearchable(): bool { - return ! $this->disabled; + // Ensure the mod is not disabled. + if ($this->disabled) { + return false; + } + + // Ensure the mod has a publish date. + if (is_null($this->published_at)) { + return false; + } + + // Fetch the latest version instance. + $latestVersion = $this->latestVersion()?->first(); + + // Ensure the mod has a latest version. + if (is_null($latestVersion)) { + return false; + } + + // Ensure the latest version has a latest SPT version. + if ($latestVersion->latestSptVersion()->doesntExist()) { + return false; + } + + // All conditions are met; the mod should be searchable. + return true; } /** diff --git a/app/Models/ModDependency.php b/app/Models/ModDependency.php index 47430da..e147196 100644 --- a/app/Models/ModDependency.php +++ b/app/Models/ModDependency.php @@ -5,12 +5,13 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id * @property int $mod_version_id * @property int $dependency_mod_id - * @property string $version_constraint + * @property string $constraint * @property int|null $resolved_version_id */ class ModDependency extends Model @@ -18,7 +19,7 @@ class ModDependency extends Model use HasFactory; /** - * The relationship between a mod dependency and mod version. + * The relationship between the mod dependency and the mod version. */ public function modVersion(): BelongsTo { @@ -26,18 +27,18 @@ class ModDependency extends Model } /** - * The relationship between the mod dependency and the mod that is depended on. + * The relationship between the mod dependency and the resolved dependency. */ - public function dependencyMod(): BelongsTo + public function resolvedDependencies(): HasMany { - return $this->belongsTo(Mod::class, 'dependency_mod_id'); + return $this->hasMany(ModResolvedDependency::class, 'dependency_id'); } /** - * The relationship between a mod dependency and resolved mod version. + * The relationship between the mod dependency and the dependent mod. */ - public function resolvedVersion(): BelongsTo + public function dependentMod(): BelongsTo { - return $this->belongsTo(ModVersion::class, 'resolved_version_id'); + return $this->belongsTo(Mod::class, 'dependent_mod_id'); } } diff --git a/app/Models/ModResolvedDependency.php b/app/Models/ModResolvedDependency.php new file mode 100644 index 0000000..ee6e747 --- /dev/null +++ b/app/Models/ModResolvedDependency.php @@ -0,0 +1,33 @@ +belongsTo(ModVersion::class, 'mod_version_id'); + } + + /** + * The relationship between the resolved dependency and the dependency. + */ + public function dependency(): BelongsTo + { + return $this->belongsTo(ModDependency::class); + } + + /** + * The relationship between the resolved dependency and the resolved mod version. + */ + public function resolvedModVersion(): BelongsTo + { + return $this->belongsTo(ModVersion::class, 'resolved_mod_version_id'); + } +} diff --git a/app/Models/ModVersion.php b/app/Models/ModVersion.php index 149d9d9..c834a41 100644 --- a/app/Models/ModVersion.php +++ b/app/Models/ModVersion.php @@ -7,6 +7,7 @@ use App\Models\Scopes\PublishedScope; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -39,22 +40,53 @@ class ModVersion extends Model /** * The relationship between a mod version and its dependencies. */ - public function dependencies(bool $resolvedOnly = true): HasMany + public function dependencies(): HasMany { - $relation = $this->hasMany(ModDependency::class); - - if ($resolvedOnly) { - $relation->whereNotNull('resolved_version_id'); - } - - return $relation; + return $this->hasMany(ModDependency::class); } /** - * The relationship between a mod version and SPT version. + * The relationship between a mod version and its resolved dependencies. */ - public function sptVersion(): BelongsTo + public function resolvedDependencies(): BelongsToMany { - return $this->belongsTo(SptVersion::class, 'resolved_spt_version_id'); + return $this->belongsToMany(ModVersion::class, 'mod_resolved_dependencies', 'mod_version_id', 'resolved_mod_version_id') + ->withPivot('dependency_id') + ->withTimestamps(); + } + + /** + * The relationship between a mod version and its each of it's resolved dependencies' latest versions. + */ + public function latestResolvedDependencies(): BelongsToMany + { + return $this->belongsToMany(ModVersion::class, 'mod_resolved_dependencies', 'mod_version_id', 'resolved_mod_version_id') + ->withPivot('dependency_id') + ->join('mod_versions as latest_versions', function ($join) { + $join->on('latest_versions.id', '=', 'mod_versions.id') + ->whereRaw('latest_versions.version = (SELECT MAX(mv.version) FROM mod_versions mv WHERE mv.mod_id = mod_versions.mod_id)'); + }) + ->with('mod:id,name,slug') + ->withTimestamps(); + } + + /** + * The relationship between a mod version and each of its SPT versions' latest version. + * Hint: Be sure to call `->first()` on this to get the actual instance. + */ + public function latestSptVersion(): BelongsToMany + { + return $this->belongsToMany(SptVersion::class, 'mod_version_spt_version') + ->orderBy('version', 'desc') + ->limit(1); + } + + /** + * The relationship between a mod version and its SPT versions. + */ + public function sptVersions(): BelongsToMany + { + return $this->belongsToMany(SptVersion::class, 'mod_version_spt_version') + ->orderByDesc('version'); } } diff --git a/app/Models/SptVersion.php b/app/Models/SptVersion.php index b1a27f7..42bd18b 100644 --- a/app/Models/SptVersion.php +++ b/app/Models/SptVersion.php @@ -6,7 +6,7 @@ use App\Exceptions\InvalidVersionNumberException; use App\Services\LatestSptVersionService; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\App; @@ -14,12 +14,20 @@ class SptVersion extends Model { use HasFactory, SoftDeletes; + /** + * Get the version with "SPT " prepended. + */ + public function getVersionFormattedAttribute(): string + { + return __('SPT ').$this->version; + } + /** * The relationship between an SPT version and mod version. */ - public function modVersions(): HasMany + public function modVersions(): BelongsToMany { - return $this->hasMany(ModVersion::class); + return $this->belongsToMany(ModVersion::class, 'mod_version_spt_version'); } /** diff --git a/app/Observers/ModDependencyObserver.php b/app/Observers/ModDependencyObserver.php index 353e873..49c3425 100644 --- a/app/Observers/ModDependencyObserver.php +++ b/app/Observers/ModDependencyObserver.php @@ -2,9 +2,7 @@ namespace App\Observers; -use App\Exceptions\CircularDependencyException; use App\Models\ModDependency; -use App\Models\ModVersion; use App\Services\DependencyVersionService; class ModDependencyObserver @@ -18,34 +16,17 @@ class ModDependencyObserver /** * Handle the ModDependency "saved" event. - * - * @throws CircularDependencyException */ public function saved(ModDependency $modDependency): void { - $this->resolveDependencyVersion($modDependency); - } - - /** - * Resolve the ModDependency's dependencies. - * - * @throws CircularDependencyException - */ - public function resolveDependencyVersion(ModDependency $modDependency): void - { - $modVersion = ModVersion::find($modDependency->mod_version_id); - if ($modVersion) { - $this->dependencyVersionService->resolve($modVersion); - } + $this->dependencyVersionService->resolve($modDependency->modVersion); } /** * Handle the ModDependency "deleted" event. - * - * @throws CircularDependencyException */ public function deleted(ModDependency $modDependency): void { - $this->resolveDependencyVersion($modDependency); + $this->dependencyVersionService->resolve($modDependency->modVersion); } } diff --git a/app/Observers/ModObserver.php b/app/Observers/ModObserver.php new file mode 100644 index 0000000..77e3947 --- /dev/null +++ b/app/Observers/ModObserver.php @@ -0,0 +1,27 @@ +dependencyVersionService = $dependencyVersionService; + } + + /** + * Handle the Mod "saved" event. + */ + public function saved(Mod $mod): void + { + foreach ($mod->versions as $modVersion) { + $this->dependencyVersionService->resolve($modVersion); + } + } +} diff --git a/app/Observers/ModVersionObserver.php b/app/Observers/ModVersionObserver.php index abd2ecd..2182f67 100644 --- a/app/Observers/ModVersionObserver.php +++ b/app/Observers/ModVersionObserver.php @@ -2,8 +2,6 @@ namespace App\Observers; -use App\Exceptions\CircularDependencyException; -use App\Models\ModDependency; use App\Models\ModVersion; use App\Services\DependencyVersionService; use App\Services\SptVersionService; @@ -24,35 +22,18 @@ class ModVersionObserver /** * Handle the ModVersion "saved" event. - * - * @throws CircularDependencyException */ public function saved(ModVersion $modVersion): void { - $this->resolveDependencyVersion($modVersion); + $this->dependencyVersionService->resolve($modVersion); $this->sptVersionService->resolve($modVersion); } - /** - * 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->dependencyVersionService->resolve($dependency->modVersion); - } - } - /** * Handle the ModVersion "deleted" event. - * - * @throws CircularDependencyException */ public function deleted(ModVersion $modVersion): void { - $this->resolveDependencyVersion($modVersion); + $this->dependencyVersionService->resolve($modVersion); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d52f31b..5bac452 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,13 @@ namespace App\Providers; +use App\Models\Mod; use App\Models\ModDependency; use App\Models\ModVersion; use App\Models\SptVersion; use App\Models\User; use App\Observers\ModDependencyObserver; +use App\Observers\ModObserver; use App\Observers\ModVersionObserver; use App\Observers\SptVersionObserver; use App\Services\LatestSptVersionService; @@ -36,6 +38,7 @@ class AppServiceProvider extends ServiceProvider Model::unguard(); // Register observers. + Mod::observe(ModObserver::class); ModVersion::observe(ModVersionObserver::class); ModDependency::observe(ModDependencyObserver::class); SptVersion::observe(SptVersionObserver::class); diff --git a/app/Services/DependencyVersionService.php b/app/Services/DependencyVersionService.php index d9f7b0c..556314e 100644 --- a/app/Services/DependencyVersionService.php +++ b/app/Services/DependencyVersionService.php @@ -2,118 +2,46 @@ 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. + * Resolve the dependencies for a mod version. */ - 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 + public function resolve(ModVersion $modVersion): void { - $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; + $dependencies = $this->satisfyConstraint($modVersion); + $modVersion->resolvedDependencies()->sync($dependencies); } /** - * Perform a depth-first search to resolve dependencies for the given mod version. - * - * @throws CircularDependencyException + * Satisfies all dependency constraints of a ModVersion. */ - protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void + private function satisfyConstraint(ModVersion $modVersion): array { - // Detect circular dependencies - if (in_array($modVersion->id, $this->stack)) { - throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}"); - } + // Eager load the dependencies and their mod versions. + $modVersion->load('dependencies.dependentMod.versions'); - // Skip already processed versions - if (in_array($modVersion->id, $this->visited)) { - return; - } + // Iterate over each ModVersion dependency. + $dependencies = []; + foreach ($modVersion->dependencies as $dependency) { - // Mark the current version - $this->visited[] = $modVersion->id; - $this->stack[] = $modVersion->id; + // Get all dependent mod versions. + $dependentModVersions = $dependency->dependentMod->versions()->get(); - // Get the dependencies for the current mod version. - $dependencies = $modVersion->dependencies(resolvedOnly: false)->get(); + // Filter the dependent mod versions to find the ones that satisfy the dependency constraint. + $matchedVersions = $dependentModVersions->filter(function ($version) use ($dependency) { + return Semver::satisfies($version->version, $dependency->constraint); + }); - 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); - } + // Map the matched versions to the sync data. + foreach ($matchedVersions as $matchedVersion) { + $dependencies[$matchedVersion->id] = ['dependency_id' => $dependency->id]; } } - // 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]]; + return $dependencies; } } diff --git a/app/Services/SptVersionService.php b/app/Services/SptVersionService.php index c480d50..8eba213 100644 --- a/app/Services/SptVersionService.php +++ b/app/Services/SptVersionService.php @@ -13,14 +13,14 @@ class SptVersionService */ public function resolve(ModVersion $modVersion): void { - $modVersion->resolved_spt_version_id = $this->satisfyconstraint($modVersion); - $modVersion->saveQuietly(); + $satisfyingVersionIds = $this->satisfyConstraint($modVersion); + $modVersion->sptVersions()->sync($satisfyingVersionIds); } /** * Satisfies the version constraint of a given ModVersion. Returns the ID of the satisfying SptVersion. */ - private function satisfyConstraint(ModVersion $modVersion): ?int + private function satisfyConstraint(ModVersion $modVersion): array { $availableVersions = SptVersion::query() ->orderBy('version', 'desc') @@ -29,14 +29,10 @@ class SptVersionService $satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $modVersion->spt_version_constraint); if (empty($satisfyingVersions)) { - return null; + return []; } - // 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]]; + // Return the IDs of all satisfying versions + return array_map(fn ($version) => $availableVersions[$version], $satisfyingVersions); } } diff --git a/app/View/Components/ModListSection.php b/app/View/Components/ModListSection.php index 23950d5..38890fb 100644 --- a/app/View/Components/ModListSection.php +++ b/app/View/Components/ModListSection.php @@ -6,7 +6,6 @@ use App\Models\Mod; use App\Models\ModVersion; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\Cache; use Illuminate\View\Component; class ModListSection extends Component @@ -26,44 +25,53 @@ class ModListSection extends Component private function fetchFeaturedMods(): Collection { - return Cache::remember('homepage-featured-mods', now()->addMinutes(5), function () { - return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) - ->withTotalDownloads() - ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']) - ->where('featured', true) - ->latest() - ->limit(6) - ->get(); - }); + return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) + ->withTotalDownloads() + ->with([ + 'latestVersion', + 'latestVersion.latestSptVersion:id,version,color_class', + 'users:id,name', + 'license:id,name,link', + ]) + ->where('featured', true) + ->latest() + ->limit(6) + ->get(); } private function fetchLatestMods(): Collection { - return Cache::remember('homepage-latest-mods', now()->addMinutes(5), function () { - return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at']) - ->withTotalDownloads() - ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']) - ->latest() - ->limit(6) - ->get(); - }); + return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at']) + ->withTotalDownloads() + ->with([ + 'latestVersion', + 'latestVersion.latestSptVersion:id,version,color_class', + 'users:id,name', + 'license:id,name,link', + ]) + ->latest() + ->limit(6) + ->get(); } private function fetchUpdatedMods(): Collection { - return Cache::remember('homepage-updated-mods', now()->addMinutes(5), function () { - return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) - ->withTotalDownloads() - ->with(['lastUpdatedVersion', 'lastUpdatedVersion.sptVersion', 'users:id,name']) - ->orderByDesc( - ModVersion::select('updated_at') - ->whereColumn('mod_id', 'mods.id') - ->orderByDesc('updated_at') - ->take(1) - ) - ->limit(6) - ->get(); - }); + return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) + ->withTotalDownloads() + ->with([ + 'latestVersion', + 'latestVersion.latestSptVersion:id,version,color_class', + 'users:id,name', + 'license:id,name,link', + ]) + ->orderByDesc( + ModVersion::select('updated_at') + ->whereColumn('mod_id', 'mods.id') + ->orderByDesc('updated_at') + ->take(1) + ) + ->limit(6) + ->get(); } public function render(): View diff --git a/database/factories/ModDependencyFactory.php b/database/factories/ModDependencyFactory.php index 012f5f8..b3318ac 100644 --- a/database/factories/ModDependencyFactory.php +++ b/database/factories/ModDependencyFactory.php @@ -16,20 +16,10 @@ class ModDependencyFactory extends Factory { return [ 'mod_version_id' => ModVersion::factory(), - 'dependency_mod_id' => Mod::factory(), - 'version_constraint' => fake()->numerify($this->generateVersionConstraint()), + 'dependent_mod_id' => Mod::factory(), + 'constraint' => '*', '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)]; - } } diff --git a/database/factories/ModVersionFactory.php b/database/factories/ModVersionFactory.php index e9a78ab..716fd20 100644 --- a/database/factories/ModVersionFactory.php +++ b/database/factories/ModVersionFactory.php @@ -14,15 +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_constraint' => $constraint, - 'resolved_spt_version_id' => null, + + // Unless a custom constraint is provided, this will also generate the required SPT versions. + 'spt_version_constraint' => $this->faker->randomElement(['^1.0', '^2.0', '>=3.0', '<4.0']), + 'virus_total_link' => fake()->url(), 'downloads' => fake()->randomNumber(), 'published_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), @@ -32,28 +32,43 @@ class ModVersionFactory extends Factory } /** - * This method generates a random version constraint from a predefined set of options. + * Configure the model factory. */ - private function generateVersionConstraint(): string + public function configure(): ModVersionFactory { - $versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#']; - - return $versionConstraints[array_rand($versionConstraints)]; + return $this->afterCreating(function (ModVersion $modVersion) { + $this->ensureSptVersionsExist($modVersion); // Create SPT Versions + }); } /** - * Indicate that the mod version should have a resolved SPT version. + * Ensure that the required SPT versions exist and are associated with the mod version. */ - public function sptVersionResolved(): static + protected function ensureSptVersionsExist(ModVersion $modVersion): void { - $constraint = fake()->numerify('#.#.#'); + $constraint = $modVersion->spt_version_constraint; - return $this->state(fn (array $attributes) => [ - 'spt_version_constraint' => $constraint, - 'resolved_spt_version_id' => SptVersion::factory()->create([ - 'version' => $constraint, - ]), - ]); + $requiredVersions = match ($constraint) { + '^1.0' => ['1.0.0', '1.1.0', '1.2.0'], + '^2.0' => ['2.0.0', '2.1.0'], + '>=3.0' => ['3.0.0', '3.1.0', '3.2.0', '4.0.0'], + '<4.0' => ['1.0.0', '2.0.0', '3.0.0'], + default => [], + }; + + // If the version is anything but the default, no SPT versions are created. + if (! $requiredVersions) { + return; + } + + foreach ($requiredVersions as $version) { + SptVersion::firstOrCreate(['version' => $version], [ + 'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']), + 'link' => $this->faker->url, + ]); + } + + $modVersion->sptVersions()->sync(SptVersion::whereIn('version', $requiredVersions)->pluck('id')->toArray()); } /** diff --git a/database/migrations/2024_05_15_022710_create_mods_table.php b/database/migrations/2024_05_15_022710_create_mods_table.php index 9846664..9e5c9d8 100644 --- a/database/migrations/2024_05_15_022710_create_mods_table.php +++ b/database/migrations/2024_05_15_022710_create_mods_table.php @@ -35,7 +35,9 @@ return new class extends Migration $table->timestamp('published_at')->nullable()->default(null); $table->timestamps(); - $table->index(['deleted_at', 'disabled'], 'mods_show_index'); + $table->index(['slug']); + $table->index(['featured']); + $table->index(['deleted_at', 'disabled', 'published_at'], 'mods_filtering_index'); }); } diff --git a/database/migrations/2024_05_15_023705_create_mod_versions_table.php b/database/migrations/2024_05_15_023705_create_mod_versions_table.php index dd92675..0301ead 100644 --- a/database/migrations/2024_05_15_023705_create_mod_versions_table.php +++ b/database/migrations/2024_05_15_023705_create_mod_versions_table.php @@ -23,11 +23,6 @@ return new class extends Migration $table->longText('description'); $table->string('link'); $table->string('spt_version_constraint'); - $table->foreignId('resolved_spt_version_id') - ->nullable() - ->constrained('spt_versions') - ->nullOnDelete() - ->cascadeOnUpdate(); $table->string('virus_total_link'); $table->unsignedBigInteger('downloads'); $table->boolean('disabled')->default(false); @@ -35,7 +30,9 @@ return new class extends Migration $table->timestamp('published_at')->nullable()->default(null); $table->timestamps(); - $table->index(['mod_id', 'deleted_at', 'disabled', 'version'], 'mod_versions_filtering_index'); + $table->index(['version']); + $table->index(['mod_id', 'deleted_at', 'disabled', 'published_at'], 'mod_versions_filtering_index'); + $table->index(['id', 'deleted_at'], 'mod_versions_id_deleted_at_index'); }); } diff --git a/database/migrations/2024_05_15_023710_create_mod_version_spt_version_table.php b/database/migrations/2024_05_15_023710_create_mod_version_spt_version_table.php new file mode 100644 index 0000000..2848d91 --- /dev/null +++ b/database/migrations/2024_05_15_023710_create_mod_version_spt_version_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('spt_version_id')->constrained('spt_versions')->cascadeOnDelete()->cascadeOnUpdate(); + $table->timestamps(); + + $table->index(['mod_version_id', 'spt_version_id'], 'mod_version_spt_version_index'); + $table->index(['spt_version_id', 'mod_version_id'], 'spt_version_mod_version_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('mod_version_spt_version'); + } +}; diff --git a/database/migrations/2024_07_25_161219_create_mod_dependencies.php b/database/migrations/2024_07_25_161219_create_mod_dependencies.php index 482e867..0e07be2 100644 --- a/database/migrations/2024_07_25_161219_create_mod_dependencies.php +++ b/database/migrations/2024_07_25_161219_create_mod_dependencies.php @@ -10,23 +10,12 @@ return new class extends Migration { Schema::create('mod_dependencies', function (Blueprint $table) { $table->id(); - $table->foreignId('mod_version_id') - ->constrained('mod_versions') - ->cascadeOnDelete() - ->cascadeOnUpdate(); - $table->foreignId('dependency_mod_id') - ->constrained('mods') - ->cascadeOnDelete() - ->cascadeOnUpdate(); - $table->string('version_constraint'); - $table->foreignId('resolved_version_id') - ->nullable() - ->constrained('mod_versions') - ->nullOnDelete() - ->cascadeOnUpdate(); + $table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('dependent_mod_id')->constrained('mods')->cascadeOnDelete()->cascadeOnUpdate(); + $table->string('constraint'); $table->timestamps(); - $table->unique(['mod_version_id', 'dependency_mod_id', 'version_constraint'], 'mod_dependencies_unique'); + $table->index(['mod_version_id', 'dependent_mod_id']); }); } diff --git a/database/migrations/2024_07_25_161219_create_mod_resolved_dependencies.php b/database/migrations/2024_07_25_161219_create_mod_resolved_dependencies.php new file mode 100644 index 0000000..7df0d45 --- /dev/null +++ b/database/migrations/2024_07_25_161219_create_mod_resolved_dependencies.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('dependency_id')->constrained('mod_dependencies')->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('resolved_mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate(); + $table->timestamps(); + + $table->index(['mod_version_id', 'dependency_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('mod_resolved_dependencies'); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index 43479a9..efade33 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,12 +1,3 @@ import "./registerViteAssets"; import "./registerAlpineLivewire"; import "./themeToggle"; - -document.addEventListener("livewire:init", () => { - Livewire.on("clear-filters", (event) => { - localStorage.removeItem("filter-query"); - localStorage.removeItem("filter-order"); - localStorage.removeItem("filter-sptVersion"); - localStorage.removeItem("filter-featured"); - }); -}); diff --git a/resources/views/components/mod-card.blade.php b/resources/views/components/mod-card.blade.php index 0f1f22e..1a2dbc7 100644 --- a/resources/views/components/mod-card.blade.php +++ b/resources/views/components/mod-card.blade.php @@ -18,8 +18,8 @@

{{ $mod->name }}

- - {{ $mod->{$versionScope}->sptVersion->version }} + + {{ $mod->{$versionScope}->latestSptVersion->first()->version_formatted }}

diff --git a/resources/views/livewire/mod/index.blade.php b/resources/views/livewire/mod/index.blade.php index 1cede53..1761406 100644 --- a/resources/views/livewire/mod/index.blade.php +++ b/resources/views/livewire/mod/index.blade.php @@ -1,35 +1,4 @@ -

+

{{ __('Mods') }}

{!! __('Explore an enhanced SPT experience with the mods available below. Check out the featured mods for a tailored solo-survival game with maximum immersion.') !!}

@@ -61,7 +30,13 @@
-

{{ __('Loading...') }}

+

+ + {{ __('Loading...') }} +

@@ -86,7 +61,7 @@
@foreach ($availableSptVersions as $index => $version) @if ($index < $half) - {{ $version->version }} + {{ $version->version }} @endif @endforeach
@@ -96,7 +71,7 @@
@foreach ($availableSptVersions as $index => $version) @if ($index >= $half) - {{ $version->version }} + {{ $version->version }} @endif @endforeach
diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index 0e25f2b..e163926 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -28,7 +28,7 @@

{{ $mod->name }} - {{ $latestVersion->version }} + {{ $mod->latestVersion->version }}

@@ -40,8 +40,8 @@

{{ Number::downloads($mod->total_downloads) }} {{ __('Downloads') }}

- - {{ $latestVersion->sptVersion->version }} {{ __('Compatible') }} + + {{ $mod->latestVersion->latestSptVersion->first()->version_formatted }} {{ __('Compatible') }}

@@ -54,8 +54,8 @@ {{-- Mobile Download Button --}} - - + + {{-- Tabs --}} @@ -108,8 +108,8 @@

{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}

- - {{ $version->sptVersion->version }} + + {{ $version->latestSptVersion->first()->version_formatted }} {{__('Virus Total Results')}}
@@ -117,20 +117,18 @@ {{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }} {{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }} - @if ($version->dependencies->isNotEmpty() && $version->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod)) + + {{-- Display latest resolved dependencies --}} + @if ($version->latestResolvedDependencies->isNotEmpty())
{{ __('Dependencies:') }} - @foreach ($version->dependencies as $dependency) - @if ($dependency->resolvedVersion?->mod) - - {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }}) - @if (!$loop->last), @endif - @endif + @foreach ($version->latestResolvedDependencies as $resolvedDependency) + {{ $resolvedDependency->mod->name }} ({{ $resolvedDependency->version }})@if (!$loop->last), @endif @endforeach
@endif -
+
{{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}} {!! Str::markdown($version->description) !!}
@@ -149,8 +147,8 @@
{{-- Desktop Download Button --}} - {{-- Additional Mod Details --}} @@ -177,21 +175,21 @@

@endif - @if ($latestVersion->virus_total_link) + @if ($mod->latestVersion->virus_total_link)
  • {{ __('Latest Version VirusTotal Result') }}

    - - {{ $latestVersion->virus_total_link }} + + {{ $mod->latestVersion->virus_total_link }}

  • @endif - @if ($latestVersion->dependencies->isNotEmpty() && $latestVersion->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod)) + @if ($mod->latestVersion->dependencies->isNotEmpty() && $mod->latestVersion->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
  • {{ __('Latest Version Dependencies') }}

    - @foreach ($latestVersion->dependencies as $dependency) + @foreach ($mod->latestVersion->dependencies as $dependency) {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }})
    diff --git a/tests/Feature/Mod/ModDependencyTest.php b/tests/Feature/Mod/ModDependencyTest.php new file mode 100644 index 0000000..b5db542 --- /dev/null +++ b/tests/Feature/Mod/ModDependencyTest.php @@ -0,0 +1,312 @@ +create(); + + $dependentMod = Mod::factory()->create(); + $dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']); + $dependentVersion2 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']); + + // Create a dependency + ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '^1.0', // Should resolve to dependentVersion1 + ]); + + // Check that the resolved dependency has been created + expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->first()) + ->not()->toBeNull() + ->resolved_mod_version_id->toBe($dependentVersion1->id); +}); + +it('resolves multiple matching versions', function () { + $modVersion = ModVersion::factory()->create(); + + $dependentMod = Mod::factory()->create(); + $dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']); + $dependentVersion2 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.1.0']); + $dependentVersion3 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']); + + // Create a dependency + ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '^1.0', // Should resolve to dependentVersion1 and dependentVersion2 + ]); + + $resolvedDependencies = ModResolvedDependency::where('mod_version_id', $modVersion->id)->get(); + + expect($resolvedDependencies->count())->toBe(2) + ->and($resolvedDependencies->pluck('resolved_mod_version_id')) + ->toContain($dependentVersion1->id) + ->toContain($dependentVersion2->id); +}); + +it('does not resolve dependencies when no versions match', function () { + $modVersion = ModVersion::factory()->create(); + + $dependentMod = Mod::factory()->create(); + ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']); + ModVersion::factory()->recycle($dependentMod)->create(['version' => '3.0.0']); + + // Create a dependency + ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '^1.0', // No versions match + ]); + + // Check that no resolved dependencies were created + expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->exists())->toBeFalse(); +}); + +it('updates resolved dependencies when constraint changes', function () { + $modVersion = ModVersion::factory()->create(); + + $dependentMod = Mod::factory()->create(); + $dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']); + $dependentVersion2 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']); + + // Create a dependency with an initial constraint + $dependency = ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '^1.0', // Should resolve to dependentVersion1 + ]); + + $resolvedDependency = ModResolvedDependency::where('mod_version_id', $modVersion->id)->first(); + expect($resolvedDependency->resolved_mod_version_id)->toBe($dependentVersion1->id); + + // Update the constraint + $dependency->update(['constraint' => '^2.0']); // Should now resolve to dependentVersion2 + + $resolvedDependency = ModResolvedDependency::where('mod_version_id', $modVersion->id)->first(); + expect($resolvedDependency->resolved_mod_version_id)->toBe($dependentVersion2->id); +}); + +it('removes resolved dependencies when dependency is removed', function () { + $modVersion = ModVersion::factory()->create(); + + $dependentMod = Mod::factory()->create(); + $dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']); + + // Create a dependency + $dependency = ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '^1.0', + ]); + + $resolvedDependency = ModResolvedDependency::where('mod_version_id', $modVersion->id)->first(); + expect($resolvedDependency)->not()->toBeNull(); + + // Delete the dependency + $dependency->delete(); + + // Check that the resolved dependency is removed + expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->exists())->toBeFalse(); +}); + +it('handles mod versions with no dependencies gracefully', function () { + $serviceSpy = $this->spy(DependencyVersionService::class); + + $modVersion = ModVersion::factory()->create(['version' => '1.0.0']); + + // Check that the service was called and that no resolved dependencies were created. + $serviceSpy->shouldHaveReceived('resolve'); + expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->exists())->toBeFalse(); +}); + +it('resolves the correct versions with a complex semver constraint', function () { + $modVersion = ModVersion::factory()->create(['version' => '1.0.0']); + + $dependentMod = Mod::factory()->create(); + $dependentVersion1 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.0.0']); + $dependentVersion2 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.2.0']); + $dependentVersion3 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '1.5.0']); + $dependentVersion4 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.0.0']); + $dependentVersion5 = ModVersion::factory()->recycle($dependentMod)->create(['version' => '2.5.0']); + + // Create a complex SemVer constraint + ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '>1.0 <2.0 || >=2.5.0 <3.0', // Should resolve to dependentVersion2, dependentVersion3, and dependentVersion5 + ]); + + $resolvedDependencies = ModResolvedDependency::where('mod_version_id', $modVersion->id)->pluck('resolved_mod_version_id'); + + expect($resolvedDependencies)->toContain($dependentVersion2->id) + ->toContain($dependentVersion3->id) + ->toContain($dependentVersion5->id) + ->not->toContain($dependentVersion1->id) + ->not->toContain($dependentVersion4->id); +}); + +it('resolves overlapping version constraints from multiple dependencies correctly', function () { + $modVersion = ModVersion::factory()->create(['version' => '1.0.0']); + + $dependentMod1 = Mod::factory()->create(); + $dependentVersion1_1 = ModVersion::factory()->recycle($dependentMod1)->create(['version' => '1.0.0']); + $dependentVersion1_2 = ModVersion::factory()->recycle($dependentMod1)->create(['version' => '1.5.0']); + + $dependentMod2 = Mod::factory()->create(); + $dependentVersion2_1 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.0.0']); + $dependentVersion2_2 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.5.0']); + + // Create two dependencies with overlapping constraints + ModDependency::factory()->recycle([$modVersion, $dependentMod1])->create([ + 'constraint' => '>=1.0 <2.0', // Matches both versions of dependentMod1 + ]); + + ModDependency::factory()->recycle([$modVersion, $dependentMod2])->create([ + 'constraint' => '>=1.5.0 <2.0.0', // Matches only the second version of dependentMod2 + ]); + + $resolvedDependencies = ModResolvedDependency::where('mod_version_id', $modVersion->id)->get(); + + expect($resolvedDependencies->pluck('resolved_mod_version_id')) + ->toContain($dependentVersion1_1->id) + ->toContain($dependentVersion1_2->id) + ->toContain($dependentVersion2_2->id) + ->not->toContain($dependentVersion2_1->id); +}); + +it('handles the case where a dependent mod has no versions available', function () { + $modVersion = ModVersion::factory()->create(['version' => '1.0.0']); + $dependentMod = Mod::factory()->create(); + + // Create a dependency where the dependent mod has no versions. + ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '>=1.0.0', + ]); + + // Verify that no versions were resolved. + expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->exists())->toBeFalse(); +}); + +it('handles a large number of versions efficiently', function () { + $versionCount = 100; + $modVersion = ModVersion::factory()->create(); + + $dependentMod = Mod::factory()->create(); + for ($i = 0; $i < $versionCount; $i++) { + ModVersion::factory()->recycle($dependentMod)->create(['version' => "1.0.$i"]); + } + + // Create a dependency with a broad constraint + ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '>=1.0.0', + ]); + + // Verify that all versions were resolved + expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->count())->toBe($versionCount); +}); + +it('calls DependencyVersionService when a Mod is updated', function () { + $mod = Mod::factory()->create(); + ModVersion::factory(2)->recycle($mod)->create(); + + $serviceSpy = $this->spy(DependencyVersionService::class); + + $mod->update(['name' => 'New Mod Name']); + + $mod->refresh(); + + expect($mod->versions)->toHaveCount(2); + foreach ($mod->versions as $modVersion) { + $serviceSpy->shouldReceive('resolve')->with($modVersion); + } +}); + +it('calls DependencyVersionService when a Mod is deleted', function () { + $mod = Mod::factory()->create(); + ModVersion::factory(2)->recycle($mod)->create(); + + $serviceSpy = $this->spy(DependencyVersionService::class); + + $mod->delete(); + + $mod->refresh(); + + expect($mod->versions)->toHaveCount(2); + foreach ($mod->versions as $modVersion) { + $serviceSpy->shouldReceive('resolve')->with($modVersion); + } +}); + +it('calls DependencyVersionService when a ModVersion is updated', function () { + $modVersion = ModVersion::factory()->create(); + + $serviceSpy = $this->spy(DependencyVersionService::class); + + $modVersion->update(['version' => '1.1.0']); + + $serviceSpy->shouldHaveReceived('resolve'); +}); + +it('calls DependencyVersionService when a ModVersion is deleted', function () { + $modVersion = ModVersion::factory()->create(); + + $serviceSpy = $this->spy(DependencyVersionService::class); + + $modVersion->delete(); + + $serviceSpy->shouldHaveReceived('resolve'); +}); + +it('calls DependencyVersionService when a ModDependency is updated', function () { + $modVersion = ModVersion::factory()->create(['version' => '1.0.0']); + $dependentMod = Mod::factory()->create(); + $modDependency = ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '^1.0', + ]); + + $serviceSpy = $this->spy(DependencyVersionService::class); + + $modDependency->update(['constraint' => '^2.0']); + + $serviceSpy->shouldHaveReceived('resolve'); +}); + +it('calls DependencyVersionService when a ModDependency is deleted', function () { + $modVersion = ModVersion::factory()->create(['version' => '1.0.0']); + $dependentMod = Mod::factory()->create(); + $modDependency = ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ + 'constraint' => '^1.0', + ]); + + $serviceSpy = $this->spy(DependencyVersionService::class); + + $modDependency->delete(); + + $serviceSpy->shouldHaveReceived('resolve'); +}); + +it('displays the latest resolved dependencies on the mod detail page', function () { + $dependentMod1 = Mod::factory()->create(); + $dependentMod1Version1 = ModVersion::factory()->recycle($dependentMod1)->create(['version' => '1.0.0']); + $dependentMod1Version2 = ModVersion::factory()->recycle($dependentMod1)->create(['version' => '2.0.0']); + + $dependentMod2 = Mod::factory()->create(); + $dependentMod2Version1 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.0.0']); + $dependentMod2Version2 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.1.0']); + $dependentMod2Version3 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.2.0']); + $dependentMod2Version4 = ModVersion::factory()->recycle($dependentMod2)->create(['version' => '1.2.1']); + + $mod = Mod::factory()->create(); + $mainModVersion = ModVersion::factory()->recycle($mod)->create(); + + ModDependency::factory()->recycle([$mainModVersion, $dependentMod1])->create(['constraint' => '>=1.0.0']); + ModDependency::factory()->recycle([$mainModVersion, $dependentMod2])->create(['constraint' => '>=1.0.0']); + + $mainModVersion->load('latestResolvedDependencies'); + + expect($mainModVersion->latestResolvedDependencies)->toHaveCount(2) + ->and($mainModVersion->latestResolvedDependencies->pluck('version')) + ->toContain($dependentMod1Version2->version) // Latest version of dependentMod1 + ->toContain($dependentMod2Version4->version); // Latest version of dependentMod2 + + $response = $this->get(route('mod.show', ['mod' => $mod->id, 'slug' => $mod->slug])); + + $response->assertSeeInOrder(explode(' ', __('Dependencies: ')."$dependentMod1->name ($dependentMod1Version2->version)")); + $response->assertSeeInOrder(explode(' ', __('Dependencies: ')."$dependentMod2->name ($dependentMod2Version4->version)")); +}); diff --git a/tests/Feature/Mod/ModFilterTest.php b/tests/Feature/Mod/ModFilterTest.php new file mode 100644 index 0000000..6e4d998 --- /dev/null +++ b/tests/Feature/Mod/ModFilterTest.php @@ -0,0 +1,161 @@ +create(['version' => '1.0.0']); + $sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']); + + $mod1 = Mod::factory()->create(); + $modVersion1 = ModVersion::factory()->recycle($mod1)->create([ + 'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1 + ]); + + $mod2 = Mod::factory()->create(); + $modVersion2 = ModVersion::factory()->recycle($mod2)->create([ + 'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2 + ]); + + // Confirm associations created by observers and services + expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version) + ->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version); + + // Apply the filter + $filters = ['sptVersions' => [$sptVersion1->version]]; + $filteredMods = (new ModFilter($filters))->apply()->get(); + + // Assert that only the correct mod is returned + expect($filteredMods)->toHaveCount(1) + ->and($filteredMods->first()->id)->toBe($mod1->id); +}); + +it('filters mods by multiple SPT versions', function () { + // Create the SPT versions + $sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']); + $sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']); + $sptVersion3 = SptVersion::factory()->create(['version' => '3.0.0']); + + // Create the mods and their versions with appropriate constraints + $mod1 = Mod::factory()->create(); + $modVersion1 = ModVersion::factory()->recycle($mod1)->create([ + 'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1 + ]); + + $mod2 = Mod::factory()->create(); + $modVersion2 = ModVersion::factory()->recycle($mod2)->create([ + 'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2 + ]); + + $mod3 = Mod::factory()->create(); + $modVersion3 = ModVersion::factory()->recycle($mod3)->create([ + 'spt_version_constraint' => '3.0.0', // Constraint matching sptVersion3 + ]); + + // Confirm associations created by observers and services + expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version) + ->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version) + ->and($modVersion3->sptVersions->pluck('version')->toArray())->toContain($sptVersion3->version); + + // Apply the filter with multiple SPT versions + $filters = ['sptVersions' => [$sptVersion1->version, $sptVersion3->version]]; + $filteredMods = (new ModFilter($filters))->apply()->get(); + + // Assert that the correct mods are returned + expect($filteredMods)->toHaveCount(2) + ->and($filteredMods->pluck('id')->toArray())->toContain($mod1->id, $mod3->id); +}); + +it('returns no mods when no SPT versions match', function () { + // Create the SPT versions + $sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']); + $sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']); + + // Create the mods and their versions with appropriate constraints + $mod1 = Mod::factory()->create(); + $modVersion1 = ModVersion::factory()->recycle($mod1)->create([ + 'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1 + ]); + + $mod2 = Mod::factory()->create(); + $modVersion2 = ModVersion::factory()->recycle($mod2)->create([ + 'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2 + ]); + + // Confirm associations created by observers and services + expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version) + ->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version); + + // Apply the filter with a non-matching SPT version + $filters = ['sptVersions' => ['3.0.0']]; // Version '3.0.0' does not exist in associations + $filteredMods = (new ModFilter($filters))->apply()->get(); + + // Assert that no mods are returned + expect($filteredMods)->toBeEmpty(); +}); + +it('filters mods correctly with combined filters', function () { + // Create the SPT versions + $sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']); + $sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']); + + // Create the mods and their versions with appropriate names and featured status + $mod1 = Mod::factory()->create(['name' => 'Awesome Mod', 'featured' => true]); + $modVersion1 = ModVersion::factory()->recycle($mod1)->create([ + 'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1 + ]); + + $mod2 = Mod::factory()->create(['name' => 'Cool Mod', 'featured' => false]); + $modVersion2 = ModVersion::factory()->recycle($mod2)->create([ + 'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2 + ]); + + // Confirm associations created by observers and services + expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version) + ->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version); + + // Apply combined filters + $filters = [ + 'query' => 'Awesome', + 'featured' => 'only', + 'sptVersions' => [$sptVersion1->version], + ]; + $filteredMods = (new ModFilter($filters))->apply()->get(); + + // Assert that only the correct mod is returned + expect($filteredMods)->toHaveCount(1) + ->and($filteredMods->first()->id)->toBe($mod1->id); +}); + +it('handles an empty SPT versions array correctly', function () { + // Create the SPT versions + $sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']); + $sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']); + + // Create the mods and their versions with appropriate constraints + $mod1 = Mod::factory()->create(); + $modVersion1 = ModVersion::factory()->recycle($mod1)->create([ + 'spt_version_constraint' => '1.0.0', // Constraint matching sptVersion1 + ]); + + $mod2 = Mod::factory()->create(); + $modVersion2 = ModVersion::factory()->recycle($mod2)->create([ + 'spt_version_constraint' => '2.0.0', // Constraint matching sptVersion2 + ]); + + // Confirm associations created by observers and services + expect($modVersion1->sptVersions->pluck('version')->toArray())->toContain($sptVersion1->version) + ->and($modVersion2->sptVersions->pluck('version')->toArray())->toContain($sptVersion2->version); + + // Apply the filter with an empty SPT versions array + $filters = ['sptVersions' => []]; + $filteredMods = (new ModFilter($filters))->apply()->get(); + + // Assert that the behavior is as expected (return all mods, or none, depending on intended behavior) + expect($filteredMods)->toHaveCount(2); // Modify this assertion to reflect your desired behavior +}); diff --git a/tests/Feature/Mod/ModTest.php b/tests/Feature/Mod/ModTest.php new file mode 100644 index 0000000..f853a64 --- /dev/null +++ b/tests/Feature/Mod/ModTest.php @@ -0,0 +1,52 @@ +create(['version' => '1.0.0']); + $sptVersion2 = SptVersion::factory()->create(['version' => '2.0.0']); + $sptVersion3 = SptVersion::factory()->create(['version' => '3.0.0']); + + $mod1 = Mod::factory()->create(); + ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion1->version]); + ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion1->version]); + ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion2->version]); + ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion2->version]); + ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion3->version]); + ModVersion::factory()->recycle($mod1)->create(['spt_version_constraint' => $sptVersion3->version]); + + $response = $this->get(route('home')); + + $response->assertSeeInOrder(explode(' ', "$mod1->name $sptVersion3->version_formatted")); +}); + +it('displays the latest version on the mod detail page', function () { + $versions = [ + '1.0.0', + '1.1.0', + '1.2.0', + '2.0.0', + '2.1.0', + ]; + $latestVersion = max($versions); + + $mod = Mod::factory()->create(); + foreach ($versions as $version) { + ModVersion::factory()->recycle($mod)->create(['version' => $version]); + } + + $response = $this->get($mod->detailUrl()); + + expect($latestVersion)->toBe('2.1.0'); + + // Assert the latest version is next to the mod's name + $response->assertSeeInOrder(explode(' ', "$mod->name $latestVersion")); + + // Assert the latest version is in the latest download button + $response->assertSeeText(__('Download Latest Version')." ($latestVersion)"); +}); diff --git a/tests/Feature/ModVersionTest.php b/tests/Feature/Mod/ModVersionTest.php similarity index 64% rename from tests/Feature/ModVersionTest.php rename to tests/Feature/Mod/ModVersionTest.php index dd4e91e..c706f0a 100644 --- a/tests/Feature/ModVersionTest.php +++ b/tests/Feature/Mod/ModVersionTest.php @@ -7,7 +7,7 @@ use Illuminate\Support\Carbon; uses(RefreshDatabase::class); -it('resolves spt version when mod version is created', function () { +it('resolves spt versions 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']); @@ -17,11 +17,13 @@ it('resolves spt version when mod version is created', function () { $modVersion->refresh(); - expect($modVersion->resolved_spt_version_id)->not->toBeNull(); - expect($modVersion->sptVersion->version)->toBe('1.1.1'); + $sptVersions = $modVersion->sptVersions; + + expect($sptVersions)->toHaveCount(2) + ->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1'); }); -it('resolves spt version when constraint is updated', function () { +it('resolves spt versions 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']); @@ -29,19 +31,25 @@ it('resolves spt version when constraint is updated', function () { $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->refresh(); + + $sptVersions = $modVersion->sptVersions; + + expect($sptVersions)->toHaveCount(2) + ->and($sptVersions->pluck('version'))->toContain('1.1.0', '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'); + $sptVersions = $modVersion->sptVersions; + + expect($sptVersions)->toHaveCount(1) + ->and($sptVersions->pluck('version'))->toContain('1.2.0'); }); -it('resolves spt version when spt version is created', function () { +it('resolves spt versions 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']); @@ -49,17 +57,24 @@ it('resolves spt version when spt version is created', function () { $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->refresh(); + + $sptVersions = $modVersion->sptVersions; + + expect($sptVersions)->toHaveCount(2) + ->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1'); SptVersion::factory()->create(['version' => '1.1.2']); $modVersion->refresh(); - expect($modVersion->sptVersion->version)->toBe('1.1.2'); + $sptVersions = $modVersion->sptVersions; + + expect($sptVersions)->toHaveCount(3) + ->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1', '1.1.2'); }); -it('resolves spt version when spt version is deleted', function () { +it('resolves spt versions 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']); @@ -67,13 +82,19 @@ it('resolves spt version when spt version is deleted', function () { $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'); + $modVersion->refresh(); + $sptVersions = $modVersion->sptVersions; + + expect($sptVersions)->toHaveCount(3) + ->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1', '1.1.2'); $sptVersion->delete(); - $modVersion->refresh(); - expect($modVersion->sptVersion->version)->toBe('1.1.1'); + $modVersion->refresh(); + $sptVersions = $modVersion->sptVersions; + + expect($sptVersions)->toHaveCount(2) + ->and($sptVersions->pluck('version'))->toContain('1.1.0', '1.1.1'); }); it('includes only published mod versions', function () { diff --git a/tests/Feature/ModDependencyTest.php b/tests/Feature/ModDependencyTest.php deleted file mode 100644 index a6f1643..0000000 --- a/tests/Feature/ModDependencyTest.php +++ /dev/null @@ -1,266 +0,0 @@ -create(['name' => 'Mod A']); - $modB = Mod::factory()->create(['name' => 'Mod B']); - - // Create versions for Mod B - 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()->recycle($modA)->create(['version' => '1.0.0']); - $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '^1.0.0', - ]); - - $modDependency->refresh(); - - expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); -}); - -it('resolves mod version dependency when mod version 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']); - $modBv3 = ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); - ModVersion::factory()->create(['mod_id' => $modB->id, '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']); - $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '^1.0.0', - ]); - - $modDependency->refresh(); - expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); - - // Update the mod B version - $modBv3->update(['version' => '1.1.2']); - - $modDependency->refresh(); - expect($modDependency->resolvedVersion->version)->toBe('1.1.2'); -}); - -it('resolves mod version dependency when mod version is deleted', 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']); - $modBv3 = ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); - ModVersion::factory()->create(['mod_id' => $modB->id, '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']); - $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '^1.0.0', - ]); - - $modDependency->refresh(); - expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); - - // Delete the mod B version - $modBv3->delete(); - - $modDependency->refresh(); - expect($modDependency->resolvedVersion->version)->toBe('1.1.0'); -}); - -it('resolves 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 versions for Mod A that depends on Mod B - $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '^1.0.0', - ]); - - $modDependency->refresh(); - expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); - - // Update the dependency version constraint - $modDependency->update(['version_constraint' => '^2.0.0']); - - $modDependency->refresh(); - expect($modDependency->resolvedVersion->version)->toBe('2.0.1'); -}); - -it('resolves mod version dependency with exact semantic version constraint', 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']); - - // Create versions for Mod A that depends on Mod B - $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '1.1.0', - ]); - - $modDependency->refresh(); - expect($modDependency->resolvedVersion->version)->toBe('1.1.0'); -}); - -it('resolves mod version dependency with complex semantic version constraint', 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' => '1.2.0']); - ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.2.1']); - ModVersion::factory()->create(['mod_id' => $modB->id, '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']); - $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '>=1.0.0 <2.0.0', - ]); - - $modDependency->refresh(); - expect($modDependency->resolvedVersion->version)->toBe('1.2.1'); - - $modDependency->update(['version_constraint' => '1.0.0 || >=1.1.0 <1.2.0']); - - $modDependency->refresh(); - 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']); - - // Create version for Mod A that has no resolvable dependency - $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '^1.0.0', - ]); - - $modDependency->refresh(); - expect($modDependency->resolved_version_id)->toBeNull(); -}); - -it('resolves null when no mod versions match against semantic version constraint', 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' => '2.0.0']); - - // Create version for Mod A that has no resolvable dependency - $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '~1.2.0', - ]); - - $modDependency->refresh(); - expect($modDependency->resolved_version_id)->toBeNull(); -}); - -it('resolves multiple dependencies', function () { - $modA = Mod::factory()->create(['name' => 'Mod A']); - $modB = Mod::factory()->create(['name' => 'Mod B']); - $modC = Mod::factory()->create(['name' => 'Mod C']); - - 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' => $modC->id, 'version' => '1.0.0']); - ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.1.0']); - ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.1.1']); - ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '2.0.0']); - - // Creating a version for Mod A that depends on Mod B and Mod C - $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - - $modDependencyB = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '^1.0.0', - ]); - $modDependencyC = ModDependency::factory()->recycle([$modAv1, $modC])->create([ - 'version_constraint' => '^1.0.0', - ]); - - $modDependencyB->refresh(); - expect($modDependencyB->resolvedVersion->version)->toBe('1.1.1'); - - $modDependencyC->refresh(); - expect($modDependencyC->resolvedVersion->version)->toBe('1.1.1'); -}); - -it('throws exception when there is a circular version dependency', function () { - $modA = Mod::factory()->create(['name' => 'Mod A']); - $modAv1 = ModVersion::factory()->recycle($modA)->create(['version' => '1.0.0']); - - $modB = Mod::factory()->create(['name' => 'Mod B']); - $modBv1 = ModVersion::factory()->recycle($modB)->create(['version' => '1.0.0']); - - $modDependencyAtoB = ModDependency::factory()->recycle([$modAv1, $modB])->create([ - 'version_constraint' => '1.0.0', - ]); - - // Create circular dependencies - $modDependencyBtoA = ModDependency::factory()->recycle([$modBv1, $modA])->create([ - 'version_constraint' => '1.0.0', - ]); -})->throws(CircularDependencyException::class); diff --git a/tests/Feature/ModTest.php b/tests/Feature/ModTest.php deleted file mode 100644 index 3365414..0000000 --- a/tests/Feature/ModTest.php +++ /dev/null @@ -1,54 +0,0 @@ -create(); - ModVersion::factory(5)->recycle($mod)->create(); - - 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', - '1.2.0', - '2.0.0', - '2.1.0', - ]; - $latestVersion = max($versions); - - $mod = Mod::factory()->create(); - foreach ($versions as $version) { - ModVersion::factory()->sptVersionResolved()->recycle($mod)->create(['version' => $version]); - } - - $response = $this->get($mod->detailUrl()); - - expect($latestVersion)->toBe('2.1.0'); - - // Assert the latest version is next to the mod's name - $response->assertSeeInOrder(explode(' ', "$mod->name $latestVersion")); - - // Assert the latest version is in the latest download button - $response->assertSeeText(__('Download Latest Version')." ($latestVersion)"); -}); diff --git a/tests/Feature/StressTest.php b/tests/Feature/StressTest.php deleted file mode 100644 index 275df32..0000000 --- a/tests/Feature/StressTest.php +++ /dev/null @@ -1,9 +0,0 @@ -requests()->duration()->med())->toBeLessThan(100); -});