feat: tag-based product alternatives

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Doar Mihai
2026-10-17 16:06:27 +03:00
co-authored by Claude Sonnet 5.5
parent fbb4862454
commit c4e6173e46
4 changed files with 160 additions and 1 deletions
+99
View File
@@ -0,0 +1,99 @@
<?php
use App\Models\Product;
use App\Models\Tag;
use App\Models\Upvote;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function altTags(int $n): array
{
return collect(range(1, $n))->map(fn ($i) => Tag::create([
'name' => "Alt Tag $i",
'slug' => "alt-tag-$i",
]))->all();
}
function altProduct(array $tags, string $state = 'approved', array $attrs = []): Product
{
$product = Product::factory()->{$state}()->create($attrs);
$product->tags()->sync(collect($tags)->pluck('id')->all());
return $product;
}
test('orders by shared tags', function () {
[$a, $b, $c, $d] = altTags(4);
$self = altProduct([$a, $b, $c]);
$two = altProduct([$a, $b]);
$three = altProduct([$a, $b, $c, $d]);
$one = altProduct([$a]);
$result = $self->alternatives();
expect($result->pluck('id')->all())->toBe([$three->id, $two->id, $one->id]);
expect($result->pluck('shared_tags_count')->map(fn ($v) => (int) $v)->all())->toBe([3, 2, 1]);
});
test('excludes self, pending, rejected and zero-shared-tag products', function () {
[$a, $b] = altTags(2);
$self = altProduct([$a]);
$good = altProduct([$a]);
altProduct([$a], 'pending');
altProduct([$a], 'rejected');
altProduct([$b]);
expect($self->alternatives()->pluck('id')->all())->toBe([$good->id]);
});
test('ties are ordered by upvotes then newest', function () {
[$a] = altTags(1);
$self = altProduct([$a]);
$old = altProduct([$a], 'approved', ['created_at' => now()->subDays(3)]);
$new = altProduct([$a], 'approved', ['created_at' => now()->subDay()]);
$voted = altProduct([$a], 'approved', ['created_at' => now()->subDays(5)]);
Upvote::create(['product_id' => $voted->id, 'user_id' => userWithRole('user')->id]);
expect($self->alternatives()->pluck('id')->all())->toBe([$voted->id, $new->id, $old->id]);
});
test('limits to 3', function () {
[$a] = altTags(1);
$self = altProduct([$a]);
foreach (range(1, 5) as $i) {
altProduct([$a]);
}
expect($self->alternatives())->toHaveCount(3);
expect($self->alternatives(2))->toHaveCount(2);
});
test('alternatives is a single query', function () {
[$a, $b] = altTags(2);
$self = altProduct([$a, $b]);
altProduct([$a]);
altProduct([$b]);
DB::enableQueryLog();
$self->alternatives();
expect(DB::getQueryLog())->toHaveCount(1);
});
test('show page renders alternative cards and hides the section when none', function () {
[$a] = altTags(1);
$self = altProduct([$a]);
$this->get(route('products.show', $self))->assertOk()->assertDontSee('data-alternatives', false);
$alt = altProduct([$a], 'approved', ['title' => 'Cool Alternative', 'eu_made' => true]);
$this->get(route('products.show', $self))
->assertOk()
->assertSee('data-alternatives', false)
->assertSee('Cool Alternative')
->assertSee(route('products.show', $alt), false)
->assertSee('1 shared tag')
->assertSee('EU made');
});