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. :(
This commit is contained in:
Refringe 2024-08-29 15:46:10 -04:00
parent da706636a8
commit 1783a683ed
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
34 changed files with 957 additions and 720 deletions

View File

@ -30,13 +30,13 @@ class ModController extends Controller
$mod = Mod::withTotalDownloads() $mod = Mod::withTotalDownloads()
->with([ ->with([
'versions', 'versions',
'versions.sptVersion', 'versions.latestSptVersion:id,version,color_class',
'versions.dependencies', 'versions.latestResolvedDependencies',
'versions.dependencies.resolvedVersion', 'versions.latestResolvedDependencies.mod:id,name,slug',
'versions.dependencies.resolvedVersion.mod',
'users:id,name', 'users:id,name',
'license:id,name,link', 'license:id,name,link',
]) ])
->whereHas('latestVersion')
->findOrFail($modId); ->findOrFail($modId);
if ($mod->slug !== $slug) { if ($mod->slug !== $slug) {
@ -45,9 +45,7 @@ class ModController extends Controller
$this->authorize('view', $mod); $this->authorize('view', $mod);
$latestVersion = $mod->versions->first(); return view('mod.show', compact(['mod']));
return view('mod.show', compact(['mod', 'latestVersion']));
} }
public function update(ModRequest $request, Mod $mod) public function update(ModRequest $request, Mod $mod)

View File

@ -31,7 +31,12 @@ class ModFilter
{ {
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at']) return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads() ->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. * 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) { return $this->builder->whereHas('latestVersion.sptVersions', function ($query) use ($versions) {
$query->whereHas('sptVersion', function ($query) use ($versions) { $query->whereIn('spt_versions.version', $versions);
$query->whereIn('version', $versions);
});
}); });
} }
} }

View File

@ -608,8 +608,6 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/** /**
* Import the SPT versions from the Hub database to the local database. * Import the SPT versions from the Hub database to the local database.
*
* @throws Exception
*/ */
protected function importSptVersions(): void protected function importSptVersions(): void
{ {
@ -938,7 +936,6 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'description' => $this->cleanHubContent($versionContent->description ?? ''), 'description' => $this->cleanHubContent($versionContent->description ?? ''),
'link' => $version->downloadURL, 'link' => $version->downloadURL,
'spt_version_constraint' => $sptVersionConstraint, 'spt_version_constraint' => $sptVersionConstraint,
'resolved_spt_version_id' => null,
'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '', 'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '',
'downloads' => max((int) $version->downloads, 0), // At least 0. 'downloads' => max((int) $version->downloads, 0), // At least 0.
'disabled' => (bool) $version->isDisabled, 'disabled' => (bool) $version->isDisabled,
@ -955,9 +952,9 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'description', 'description',
'link', 'link',
'spt_version_constraint', 'spt_version_constraint',
'resolved_spt_version_id',
'virus_total_link', 'virus_total_link',
'downloads', 'downloads',
'disabled',
'published_at', 'published_at',
'created_at', 'created_at',
'updated_at', 'updated_at',

View File

@ -6,7 +6,9 @@ use App\Http\Filters\ModFilter;
use App\Models\SptVersion; use App\Models\SptVersion;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Session;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -19,24 +21,28 @@ class Index extends Component
* The search query value. * The search query value.
*/ */
#[Url] #[Url]
#[Session]
public string $query = ''; public string $query = '';
/** /**
* The sort order value. * The sort order value.
*/ */
#[Url] #[Url]
#[Session]
public string $order = 'created'; public string $order = 'created';
/** /**
* The SPT version filter value. * The SPT versions filter value.
*/ */
#[Url] #[Url]
public array $sptVersion = []; #[Session]
public array $sptVersions = [];
/** /**
* The featured filter value. * The featured filter value.
*/ */
#[Url] #[Url]
#[Session]
public string $featured = 'include'; public string $featured = 'include';
/** /**
@ -49,11 +55,13 @@ class Index extends Component
*/ */
public function mount(): void public function mount(): void
{ {
// TODO: This should be updated to only pull versions that have mods associated with them. // TODO: This should ideally be updated to only pull SPT versions that have mods associated with them so that no
// To do this, the ModVersion to SptVersion relationship needs to be converted to a many-to-many relationship. Ugh. // empty options are shown in the listing filter.
$this->availableSptVersions = SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get(); $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, 'query' => $this->query,
'featured' => $this->featured, 'featured' => $this->featured,
'order' => $this->order, 'order' => $this->order,
'sptVersion' => $this->sptVersion, 'sptVersions' => $this->sptVersions,
]; ];
$mods = (new ModFilter($filters))->apply()->paginate(16); $mods = (new ModFilter($filters))->apply()->paginate(16);
@ -89,7 +97,7 @@ class Index extends Component
public function resetFilters(): void public function resetFilters(): void
{ {
$this->query = ''; $this->query = '';
$this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray(); $this->sptVersions = $this->getLatestMinorVersions()->pluck('version')->toArray();
$this->featured = 'include'; $this->featured = 'include';
// Clear local storage // Clear local storage
@ -109,7 +117,7 @@ class Index extends Component
if ($this->featured !== 'include') { if ($this->featured !== 'include') {
$count++; $count++;
} }
$count += count($this->sptVersion); $count += count($this->sptVersions);
return $count; return $count;
} }

View File

@ -58,16 +58,11 @@ class Mod extends Model
/** /**
* The relationship between a mod and its versions. * 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'); ->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. * 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'); ->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 public function toSearchableArray(): array
{ {
$latestVersion = $this->latestVersion()->with('sptVersion')->first();
return [ return [
'id' => (int) $this->id, 'id' => $this->id,
'name' => $this->name, 'name' => $this->name,
'slug' => $this->slug, 'slug' => $this->slug,
'description' => $this->description, 'description' => $this->description,
@ -113,26 +101,21 @@ class Mod extends Model
'created_at' => strtotime($this->created_at), 'created_at' => strtotime($this->created_at),
'updated_at' => strtotime($this->updated_at), 'updated_at' => strtotime($this->updated_at),
'published_at' => strtotime($this->published_at), 'published_at' => strtotime($this->published_at),
'latestVersion' => $latestVersion?->sptVersion->version, 'latestVersion' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->version_formatted,
'latestVersionColorClass' => $latestVersion?->sptVersion->color_class, 'latestVersionColorClass' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->color_class,
]; ];
} }
/** /**
* The relationship to the latest mod version, dictated by the mod version number. * 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('version')
->orderByDesc('updated_at') ->orderByDesc('updated_at')
->take(1); ->take(1);
if ($resolvedOnly) {
$relation->whereNotNull('resolved_spt_version_id');
}
return $relation;
} }
/** /**
@ -140,7 +123,31 @@ class Mod extends Model
*/ */
public function shouldBeSearchable(): bool 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;
} }
/** /**

View File

@ -5,12 +5,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @property int $id * @property int $id
* @property int $mod_version_id * @property int $mod_version_id
* @property int $dependency_mod_id * @property int $dependency_mod_id
* @property string $version_constraint * @property string $constraint
* @property int|null $resolved_version_id * @property int|null $resolved_version_id
*/ */
class ModDependency extends Model class ModDependency extends Model
@ -18,7 +19,7 @@ class ModDependency extends Model
use HasFactory; 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 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');
} }
} }

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ModResolvedDependency extends Model
{
/**
* The relationship between the resolved dependency and the mod version.
*/
public function modVersion(): BelongsTo
{
return $this->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');
}
}

View File

@ -7,6 +7,7 @@ use App\Models\Scopes\PublishedScope;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@ -39,22 +40,53 @@ class ModVersion extends Model
/** /**
* The relationship between a mod version and its dependencies. * 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); return $this->hasMany(ModDependency::class);
if ($resolvedOnly) {
$relation->whereNotNull('resolved_version_id');
}
return $relation;
} }
/** /**
* 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');
} }
} }

View File

@ -6,7 +6,7 @@ use App\Exceptions\InvalidVersionNumberException;
use App\Services\LatestSptVersionService; use App\Services\LatestSptVersionService;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
@ -14,12 +14,20 @@ class SptVersion extends Model
{ {
use HasFactory, SoftDeletes; 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. * 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');
} }
/** /**

View File

@ -2,9 +2,7 @@
namespace App\Observers; namespace App\Observers;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency; use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Services\DependencyVersionService; use App\Services\DependencyVersionService;
class ModDependencyObserver class ModDependencyObserver
@ -18,34 +16,17 @@ class ModDependencyObserver
/** /**
* Handle the ModDependency "saved" event. * Handle the ModDependency "saved" event.
*
* @throws CircularDependencyException
*/ */
public function saved(ModDependency $modDependency): void public function saved(ModDependency $modDependency): void
{ {
$this->resolveDependencyVersion($modDependency); $this->dependencyVersionService->resolve($modDependency->modVersion);
}
/**
* 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);
}
} }
/** /**
* Handle the ModDependency "deleted" event. * Handle the ModDependency "deleted" event.
*
* @throws CircularDependencyException
*/ */
public function deleted(ModDependency $modDependency): void public function deleted(ModDependency $modDependency): void
{ {
$this->resolveDependencyVersion($modDependency); $this->dependencyVersionService->resolve($modDependency->modVersion);
} }
} }

View File

@ -0,0 +1,27 @@
<?php
namespace App\Observers;
use App\Models\Mod;
use App\Services\DependencyVersionService;
class ModObserver
{
protected DependencyVersionService $dependencyVersionService;
public function __construct(
DependencyVersionService $dependencyVersionService,
) {
$this->dependencyVersionService = $dependencyVersionService;
}
/**
* Handle the Mod "saved" event.
*/
public function saved(Mod $mod): void
{
foreach ($mod->versions as $modVersion) {
$this->dependencyVersionService->resolve($modVersion);
}
}
}

View File

@ -2,8 +2,6 @@
namespace App\Observers; namespace App\Observers;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion; use App\Models\ModVersion;
use App\Services\DependencyVersionService; use App\Services\DependencyVersionService;
use App\Services\SptVersionService; use App\Services\SptVersionService;
@ -24,35 +22,18 @@ class ModVersionObserver
/** /**
* Handle the ModVersion "saved" event. * Handle the ModVersion "saved" event.
*
* @throws CircularDependencyException
*/ */
public function saved(ModVersion $modVersion): void public function saved(ModVersion $modVersion): void
{ {
$this->resolveDependencyVersion($modVersion); $this->dependencyVersionService->resolve($modVersion);
$this->sptVersionService->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. * Handle the ModVersion "deleted" event.
*
* @throws CircularDependencyException
*/ */
public function deleted(ModVersion $modVersion): void public function deleted(ModVersion $modVersion): void
{ {
$this->resolveDependencyVersion($modVersion); $this->dependencyVersionService->resolve($modVersion);
} }
} }

View File

@ -2,11 +2,13 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Mod;
use App\Models\ModDependency; use App\Models\ModDependency;
use App\Models\ModVersion; use App\Models\ModVersion;
use App\Models\SptVersion; use App\Models\SptVersion;
use App\Models\User; use App\Models\User;
use App\Observers\ModDependencyObserver; use App\Observers\ModDependencyObserver;
use App\Observers\ModObserver;
use App\Observers\ModVersionObserver; use App\Observers\ModVersionObserver;
use App\Observers\SptVersionObserver; use App\Observers\SptVersionObserver;
use App\Services\LatestSptVersionService; use App\Services\LatestSptVersionService;
@ -36,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
Model::unguard(); Model::unguard();
// Register observers. // Register observers.
Mod::observe(ModObserver::class);
ModVersion::observe(ModVersionObserver::class); ModVersion::observe(ModVersionObserver::class);
ModDependency::observe(ModDependencyObserver::class); ModDependency::observe(ModDependencyObserver::class);
SptVersion::observe(SptVersionObserver::class); SptVersion::observe(SptVersionObserver::class);

View File

@ -2,118 +2,46 @@
namespace App\Services; namespace App\Services;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion; use App\Models\ModVersion;
use Composer\Semver\Semver; use Composer\Semver\Semver;
class DependencyVersionService class DependencyVersionService
{ {
/** /**
* Keep track of visited versions to avoid resolving them again. * Resolve the dependencies for a mod version.
*/ */
protected array $visited = []; public function resolve(ModVersion $modVersion): void
/**
* 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 = []; $dependencies = $this->satisfyConstraint($modVersion);
$this->stack = []; $modVersion->resolvedDependencies()->sync($dependencies);
// 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. * Satisfies all dependency constraints of a ModVersion.
*
* @throws CircularDependencyException
*/ */
protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void private function satisfyConstraint(ModVersion $modVersion): array
{ {
// Detect circular dependencies // Eager load the dependencies and their mod versions.
if (in_array($modVersion->id, $this->stack)) { $modVersion->load('dependencies.dependentMod.versions');
throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}");
}
// Skip already processed versions // Iterate over each ModVersion dependency.
if (in_array($modVersion->id, $this->visited)) { $dependencies = [];
return; foreach ($modVersion->dependencies as $dependency) {
}
// Mark the current version // Get all dependent mod versions.
$this->visited[] = $modVersion->id; $dependentModVersions = $dependency->dependentMod->versions()->get();
$this->stack[] = $modVersion->id;
// Get the dependencies for the current mod version. // Filter the dependent mod versions to find the ones that satisfy the dependency constraint.
$dependencies = $modVersion->dependencies(resolvedOnly: false)->get(); $matchedVersions = $dependentModVersions->filter(function ($version) use ($dependency) {
return Semver::satisfies($version->version, $dependency->constraint);
});
foreach ($dependencies as $dependency) { // Map the matched versions to the sync data.
// Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency. foreach ($matchedVersions as $matchedVersion) {
$resolvedId = $this->resolveDependency($dependency); $dependencies[$matchedVersion->id] = ['dependency_id' => $dependency->id];
// 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. return $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]];
} }
} }

View File

@ -13,14 +13,14 @@ class SptVersionService
*/ */
public function resolve(ModVersion $modVersion): void public function resolve(ModVersion $modVersion): void
{ {
$modVersion->resolved_spt_version_id = $this->satisfyconstraint($modVersion); $satisfyingVersionIds = $this->satisfyConstraint($modVersion);
$modVersion->saveQuietly(); $modVersion->sptVersions()->sync($satisfyingVersionIds);
} }
/** /**
* Satisfies the version constraint of a given ModVersion. Returns the ID of the satisfying SptVersion. * 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() $availableVersions = SptVersion::query()
->orderBy('version', 'desc') ->orderBy('version', 'desc')
@ -29,14 +29,10 @@ class SptVersionService
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $modVersion->spt_version_constraint); $satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $modVersion->spt_version_constraint);
if (empty($satisfyingVersions)) { if (empty($satisfyingVersions)) {
return null; return [];
} }
// Ensure the satisfying versions are sorted in descending order to get the latest version // Return the IDs of all satisfying versions
usort($satisfyingVersions, 'version_compare'); return array_map(fn ($version) => $availableVersions[$version], $satisfyingVersions);
$satisfyingVersions = array_reverse($satisfyingVersions);
// Return the ID of the latest satisfying version
return $availableVersions[$satisfyingVersions[0]];
} }
} }

View File

@ -6,7 +6,6 @@ use App\Models\Mod;
use App\Models\ModVersion; use App\Models\ModVersion;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\Component; use Illuminate\View\Component;
class ModListSection extends Component class ModListSection extends Component
@ -26,44 +25,53 @@ class ModListSection extends Component
private function fetchFeaturedMods(): Collection private function fetchFeaturedMods(): Collection
{ {
return Cache::remember('homepage-featured-mods', now()->addMinutes(5), function () { return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) ->withTotalDownloads()
->withTotalDownloads() ->with([
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']) 'latestVersion',
->where('featured', true) 'latestVersion.latestSptVersion:id,version,color_class',
->latest() 'users:id,name',
->limit(6) 'license:id,name,link',
->get(); ])
}); ->where('featured', true)
->latest()
->limit(6)
->get();
} }
private function fetchLatestMods(): Collection 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'])
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at']) ->withTotalDownloads()
->withTotalDownloads() ->with([
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']) 'latestVersion',
->latest() 'latestVersion.latestSptVersion:id,version,color_class',
->limit(6) 'users:id,name',
->get(); 'license:id,name,link',
}); ])
->latest()
->limit(6)
->get();
} }
private function fetchUpdatedMods(): Collection private function fetchUpdatedMods(): Collection
{ {
return Cache::remember('homepage-updated-mods', now()->addMinutes(5), function () { return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) ->withTotalDownloads()
->withTotalDownloads() ->with([
->with(['lastUpdatedVersion', 'lastUpdatedVersion.sptVersion', 'users:id,name']) 'latestVersion',
->orderByDesc( 'latestVersion.latestSptVersion:id,version,color_class',
ModVersion::select('updated_at') 'users:id,name',
->whereColumn('mod_id', 'mods.id') 'license:id,name,link',
->orderByDesc('updated_at') ])
->take(1) ->orderByDesc(
) ModVersion::select('updated_at')
->limit(6) ->whereColumn('mod_id', 'mods.id')
->get(); ->orderByDesc('updated_at')
}); ->take(1)
)
->limit(6)
->get();
} }
public function render(): View public function render(): View

View File

@ -16,20 +16,10 @@ class ModDependencyFactory extends Factory
{ {
return [ return [
'mod_version_id' => ModVersion::factory(), 'mod_version_id' => ModVersion::factory(),
'dependency_mod_id' => Mod::factory(), 'dependent_mod_id' => Mod::factory(),
'version_constraint' => fake()->numerify($this->generateVersionConstraint()), 'constraint' => '*',
'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), 'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
'updated_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)];
}
} }

View File

@ -14,15 +14,15 @@ class ModVersionFactory extends Factory
public function definition(): array public function definition(): array
{ {
$constraint = fake()->numerify($this->generateVersionConstraint());
return [ return [
'mod_id' => Mod::factory(), 'mod_id' => Mod::factory(),
'version' => fake()->numerify('#.#.#'), 'version' => fake()->numerify('#.#.#'),
'description' => fake()->text(), 'description' => fake()->text(),
'link' => fake()->url(), '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(), 'virus_total_link' => fake()->url(),
'downloads' => fake()->randomNumber(), 'downloads' => fake()->randomNumber(),
'published_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), '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 $this->afterCreating(function (ModVersion $modVersion) {
$this->ensureSptVersionsExist($modVersion); // Create SPT Versions
return $versionConstraints[array_rand($versionConstraints)]; });
} }
/** /**
* 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) => [ $requiredVersions = match ($constraint) {
'spt_version_constraint' => $constraint, '^1.0' => ['1.0.0', '1.1.0', '1.2.0'],
'resolved_spt_version_id' => SptVersion::factory()->create([ '^2.0' => ['2.0.0', '2.1.0'],
'version' => $constraint, '>=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());
} }
/** /**

View File

@ -35,7 +35,9 @@ return new class extends Migration
$table->timestamp('published_at')->nullable()->default(null); $table->timestamp('published_at')->nullable()->default(null);
$table->timestamps(); $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');
}); });
} }

View File

@ -23,11 +23,6 @@ return new class extends Migration
$table->longText('description'); $table->longText('description');
$table->string('link'); $table->string('link');
$table->string('spt_version_constraint'); $table->string('spt_version_constraint');
$table->foreignId('resolved_spt_version_id')
->nullable()
->constrained('spt_versions')
->nullOnDelete()
->cascadeOnUpdate();
$table->string('virus_total_link'); $table->string('virus_total_link');
$table->unsignedBigInteger('downloads'); $table->unsignedBigInteger('downloads');
$table->boolean('disabled')->default(false); $table->boolean('disabled')->default(false);
@ -35,7 +30,9 @@ return new class extends Migration
$table->timestamp('published_at')->nullable()->default(null); $table->timestamp('published_at')->nullable()->default(null);
$table->timestamps(); $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');
}); });
} }

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mod_version_spt_version', function (Blueprint $table) {
$table->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');
}
};

View File

@ -10,23 +10,12 @@ return new class extends Migration
{ {
Schema::create('mod_dependencies', function (Blueprint $table) { Schema::create('mod_dependencies', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('mod_version_id') $table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate();
->constrained('mod_versions') $table->foreignId('dependent_mod_id')->constrained('mods')->cascadeOnDelete()->cascadeOnUpdate();
->cascadeOnDelete() $table->string('constraint');
->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->timestamps(); $table->timestamps();
$table->unique(['mod_version_id', 'dependency_mod_id', 'version_constraint'], 'mod_dependencies_unique'); $table->index(['mod_version_id', 'dependent_mod_id']);
}); });
} }

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mod_resolved_dependencies', function (Blueprint $table) {
$table->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');
}
};

View File

@ -1,12 +1,3 @@
import "./registerViteAssets"; import "./registerViteAssets";
import "./registerAlpineLivewire"; import "./registerAlpineLivewire";
import "./themeToggle"; 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");
});
});

View File

@ -18,8 +18,8 @@
<div class="pb-3"> <div class="pb-3">
<div class="flex justify-between items-center space-x-3"> <div class="flex justify-between items-center space-x-3">
<h3 class="block mt-1 text-lg leading-tight font-medium text-black dark:text-white group-hover:underline">{{ $mod->name }}</h3> <h3 class="block mt-1 text-lg leading-tight font-medium text-black dark:text-white group-hover:underline">{{ $mod->name }}</h3>
<span class="badge-version {{ $mod->{$versionScope}->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap"> <span class="badge-version {{ $mod->{$versionScope}->latestSptVersion->first()->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $mod->{$versionScope}->sptVersion->version }} {{ $mod->{$versionScope}->latestSptVersion->first()->version_formatted }}
</span> </span>
</div> </div>
<p class="text-sm italic text-slate-600 dark:text-gray-200"> <p class="text-sm italic text-slate-600 dark:text-gray-200">

View File

@ -1,35 +1,4 @@
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8" <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
x-data="{
query: @entangle('query'),
order: @entangle('order'),
sptVersion: @entangle('sptVersion'),
featured: @entangle('featured'),
init() {
this.loadFiltersFromLocalStorage();
$wire.$refresh();
$watch('query', value => this.saveFilterToLocalStorage('query', value));
$watch('order', value => this.saveFilterToLocalStorage('order', value));
$watch('sptVersion', value => this.saveFilterToLocalStorage('sptVersion', value));
$watch('featured', value => this.saveFilterToLocalStorage('featured', value));
},
saveFilterToLocalStorage(key, value) {
localStorage.setItem(`filter-${key}`, JSON.stringify(value));
},
loadFiltersFromLocalStorage() {
const query = localStorage.getItem('filter-query');
if (query) this.query = JSON.parse(query);
const order = localStorage.getItem('filter-order');
if (order) this.order = JSON.parse(order);
const sptVersion = localStorage.getItem('filter-sptVersion');
if (sptVersion) this.sptVersion = JSON.parse(sptVersion);
const featured = localStorage.getItem('filter-featured');
if (featured) this.featured = JSON.parse(featured);
},
}">
<div class="px-4 py-8 sm:px-6 lg:px-8 bg-white dark:bg-gray-900 overflow-hidden shadow-xl dark:shadow-gray-900 rounded-none sm:rounded-lg"> <div class="px-4 py-8 sm:px-6 lg:px-8 bg-white dark:bg-gray-900 overflow-hidden shadow-xl dark:shadow-gray-900 rounded-none sm:rounded-lg">
<h1 class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-200">{{ __('Mods') }}</h1> <h1 class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-200">{{ __('Mods') }}</h1>
<p class="mt-4 text-base text-slate-500 dark:text-gray-300">{!! __('Explore an enhanced <abbr title="Single Player Tarkov">SPT</abbr> experience with the mods available below. Check out the featured mods for a tailored solo-survival game with maximum immersion.') !!}</p> <p class="mt-4 text-base text-slate-500 dark:text-gray-300">{!! __('Explore an enhanced <abbr title="Single Player Tarkov">SPT</abbr> experience with the mods available below. Check out the featured mods for a tailored solo-survival game with maximum immersion.') !!}</p>
@ -61,7 +30,13 @@
<button @click="$wire.call('resetFilters')" type="button" class="pl-6 text-gray-500 dark:text-gray-300">{{ __('Reset Filters') }}</button> <button @click="$wire.call('resetFilters')" type="button" class="pl-6 text-gray-500 dark:text-gray-300">{{ __('Reset Filters') }}</button>
<div wire:loading.flex> <div wire:loading.flex>
<p class="pl-6 flex items-center font-medium text-gray-700 dark:text-gray-300">{{ __('Loading...') }}</p> <p class="pl-6 flex items-center font-medium text-gray-700 dark:text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" class="w-4 h-4 fill-cyan-600 dark:fill-cyan-600 motion-safe:animate-spin">
<path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25" />
<path d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" />
</svg>
<span class="pl-1.5">{{ __('Loading...') }}</span>
</p>
</div> </div>
</div> </div>
</div> </div>
@ -86,7 +61,7 @@
<div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4"> <div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4">
@foreach ($availableSptVersions as $index => $version) @foreach ($availableSptVersions as $index => $version)
@if ($index < $half) @if ($index < $half)
<x-filter-checkbox id="sptVersion-{{ $index }}" name="sptVersion" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox> <x-filter-checkbox id="sptVersions-{{ $index }}" name="sptVersions" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox>
@endif @endif
@endforeach @endforeach
</div> </div>
@ -96,7 +71,7 @@
<div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4"> <div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4">
@foreach ($availableSptVersions as $index => $version) @foreach ($availableSptVersions as $index => $version)
@if ($index >= $half) @if ($index >= $half)
<x-filter-checkbox id="sptVersion-{{ $index }}" name="sptVersion" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox> <x-filter-checkbox id="sptVersions-{{ $index }}" name="sptVersions" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox>
@endif @endif
@endforeach @endforeach
</div> </div>

View File

@ -28,7 +28,7 @@
<h2 class="pb-1 sm:pb-2 text-3xl font-bold text-gray-900 dark:text-white"> <h2 class="pb-1 sm:pb-2 text-3xl font-bold text-gray-900 dark:text-white">
{{ $mod->name }} {{ $mod->name }}
<span class="font-light text-nowrap text-gray-700 dark:text-gray-400"> <span class="font-light text-nowrap text-gray-700 dark:text-gray-400">
{{ $latestVersion->version }} {{ $mod->latestVersion->version }}
</span> </span>
</h2> </h2>
</div> </div>
@ -40,8 +40,8 @@
</p> </p>
<p title="{{ __('Exactly') }} {{ $mod->total_downloads }}">{{ Number::downloads($mod->total_downloads) }} {{ __('Downloads') }}</p> <p title="{{ __('Exactly') }} {{ $mod->total_downloads }}">{{ Number::downloads($mod->total_downloads) }} {{ __('Downloads') }}</p>
<p class="mt-2"> <p class="mt-2">
<span class="badge-version {{ $latestVersion->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap"> <span class="badge-version {{ $mod->latestVersion->latestSptVersion->first()->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }} {{ $mod->latestVersion->latestSptVersion->first()->version_formatted }} {{ __('Compatible') }}
</span> </span>
</p> </p>
</div> </div>
@ -54,8 +54,8 @@
</div> </div>
{{-- Mobile Download Button --}} {{-- Mobile Download Button --}}
<a href="{{ $latestVersion->link }}" class="block lg:hidden"> <a href="{{ $mod->latestVersion->link }}" class="block lg:hidden">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $latestVersion->version }})</button> <button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $mod->latestVersion->version }})</button>
</a> </a>
{{-- Tabs --}} {{-- Tabs --}}
@ -108,8 +108,8 @@
<p class="text-gray-700 dark:text-gray-300" title="{{ __('Exactly') }} {{ $version->downloads }}">{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}</p> <p class="text-gray-700 dark:text-gray-300" title="{{ __('Exactly') }} {{ $version->downloads }}">{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}</p>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="badge-version {{ $version->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap"> <span class="badge-version {{ $version->latestSptVersion->first()->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $version->sptVersion->version }} {{ $version->latestSptVersion->first()->version_formatted }}
</span> </span>
<a href="{{ $version->virus_total_link }}">{{__('Virus Total Results')}}</a> <a href="{{ $version->virus_total_link }}">{{__('Virus Total Results')}}</a>
</div> </div>
@ -117,20 +117,18 @@
<span>{{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }}</span> <span>{{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }}</span>
<span>{{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }}</span> <span>{{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }}</span>
</div> </div>
@if ($version->dependencies->isNotEmpty() && $version->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
{{-- Display latest resolved dependencies --}}
@if ($version->latestResolvedDependencies->isNotEmpty())
<div class="text-gray-600 dark:text-gray-400"> <div class="text-gray-600 dark:text-gray-400">
{{ __('Dependencies:') }} {{ __('Dependencies:') }}
@foreach ($version->dependencies as $dependency) @foreach ($version->latestResolvedDependencies as $resolvedDependency)
@if ($dependency->resolvedVersion?->mod) <a href="{{ $resolvedDependency->mod->detailUrl() }}">{{ $resolvedDependency->mod->name }}&nbsp;({{ $resolvedDependency->version }})</a>@if (!$loop->last), @endif
<a href="{{ $dependency->resolvedVersion->mod->detailUrl() }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a>@if (!$loop->last), @endif
@endif
@endforeach @endforeach
</div> </div>
@endif @endif
</div> </div>
<div class="p-3 user-markdown"> <div class="py-3 user-markdown">
{{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}} {{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}}
{!! Str::markdown($version->description) !!} {!! Str::markdown($version->description) !!}
</div> </div>
@ -149,8 +147,8 @@
<div class="col-span-1 flex flex-col gap-6"> <div class="col-span-1 flex flex-col gap-6">
{{-- Desktop Download Button --}} {{-- Desktop Download Button --}}
<a href="{{ $latestVersion->link }}" class="hidden lg:block"> <a href="{{ $mod->latestVersion->link }}" class="hidden lg:block">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $latestVersion->version }})</button> <button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $mod->latestVersion->version }})</button>
</a> </a>
{{-- Additional Mod Details --}} {{-- Additional Mod Details --}}
@ -177,21 +175,21 @@
</p> </p>
</li> </li>
@endif @endif
@if ($latestVersion->virus_total_link) @if ($mod->latestVersion->virus_total_link)
<li class="px-4 py-4 sm:px-0"> <li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest Version VirusTotal Result') }}</h3> <h3>{{ __('Latest Version VirusTotal Result') }}</h3>
<p class="truncate"> <p class="truncate">
<a href="{{ $latestVersion->virus_total_link }}" title="{{ $latestVersion->virus_total_link }}" target="_blank"> <a href="{{ $mod->latestVersion->virus_total_link }}" title="{{ $mod->latestVersion->virus_total_link }}" target="_blank">
{{ $latestVersion->virus_total_link }} {{ $mod->latestVersion->virus_total_link }}
</a> </a>
</p> </p>
</li> </li>
@endif @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))
<li class="px-4 py-4 sm:px-0"> <li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest Version Dependencies') }}</h3> <h3>{{ __('Latest Version Dependencies') }}</h3>
<p class="truncate"> <p class="truncate">
@foreach ($latestVersion->dependencies as $dependency) @foreach ($mod->latestVersion->dependencies as $dependency)
<a href="{{ $dependency->resolvedVersion->mod->detailUrl() }}"> <a href="{{ $dependency->resolvedVersion->mod->detailUrl() }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }}) {{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a><br /> </a><br />

View File

@ -0,0 +1,312 @@
<?php
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModResolvedDependency;
use App\Models\ModVersion;
use App\Services\DependencyVersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves mod version dependencies on create', 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
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)"));
});

View File

@ -0,0 +1,161 @@
<?php
use App\Http\Filters\ModFilter;
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('filters mods by a single SPT version', function () {
$sptVersion1 = SptVersion::factory()->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
});

View File

@ -0,0 +1,52 @@
<?php
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('displays homepage mod cards with the latest supported spt version number', function () {
$sptVersion1 = SptVersion::factory()->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)");
});

View File

@ -7,7 +7,7 @@ use Illuminate\Support\Carbon;
uses(RefreshDatabase::class); 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.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']); SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']); SptVersion::factory()->create(['version' => '1.1.1']);
@ -17,11 +17,13 @@ it('resolves spt version when mod version is created', function () {
$modVersion->refresh(); $modVersion->refresh();
expect($modVersion->resolved_spt_version_id)->not->toBeNull(); $sptVersions = $modVersion->sptVersions;
expect($modVersion->sptVersion->version)->toBe('1.1.1');
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.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']); SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']); 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']); $modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull(); $modVersion->refresh();
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');
$modVersion->spt_version_constraint = '~1.2.0'; $modVersion->spt_version_constraint = '~1.2.0';
$modVersion->save(); $modVersion->save();
$modVersion->refresh(); $modVersion->refresh();
expect($modVersion->resolved_spt_version_id)->not->toBeNull(); $sptVersions = $modVersion->sptVersions;
expect($modVersion->sptVersion->version)->toBe('1.2.0');
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.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']); SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']); 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']); $modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull(); $modVersion->refresh();
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');
SptVersion::factory()->create(['version' => '1.1.2']); SptVersion::factory()->create(['version' => '1.1.2']);
$modVersion->refresh(); $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.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']); SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']); 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']); $modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull(); $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');
$sptVersion->delete(); $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 () { it('includes only published mod versions', function () {

View File

@ -1,266 +0,0 @@
<?php
use App\Exceptions\CircularDependencyException;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves mod version dependency when mod version is created', function () {
$modA = Mod::factory()->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);

View File

@ -1,54 +0,0 @@
<?php
use App\Models\Mod;
use App\Models\ModVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('can retrieve all unresolved versions', function () {
// Create a mod instance
$mod = Mod::factory()->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)");
});

View File

@ -1,9 +0,0 @@
<?php
use function Pest\Stressless\stress;
it('homepage has a fast response time', function () {
$result = stress('/');
expect($result->requests()->duration()->med())->toBeLessThan(100);
});