feat: add tag discovery analytics and reporting
This commit is contained in:
126
tests/Unit/StudioTagSearchRouteTest.php
Normal file
126
tests/Unit/StudioTagSearchRouteTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Schema::dropIfExists('tag_interaction_daily_metrics');
|
||||
Schema::dropIfExists('tags');
|
||||
Schema::dropIfExists('users');
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('username')->nullable();
|
||||
$table->timestamp('username_changed_at')->nullable();
|
||||
$table->timestamp('last_username_change_at')->nullable();
|
||||
$table->string('onboarding_step')->nullable();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->string('role')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('tags', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->unsignedInteger('usage_count')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->date('metric_date');
|
||||
$table->string('surface', 32);
|
||||
$table->string('tag_slug', 120)->default('');
|
||||
$table->string('source_tag_slug', 120)->default('');
|
||||
$table->string('query', 120)->default('');
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->unsignedInteger('unique_users')->default(0);
|
||||
$table->unsignedInteger('unique_sessions')->default(0);
|
||||
$table->decimal('avg_position', 8, 2)->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects guests away from the studio tag search route', function (): void {
|
||||
$this->get('/api/studio/tags/search?q=hi')
|
||||
->assertRedirect('/login');
|
||||
});
|
||||
|
||||
it('returns momentum-ranked tag suggestions for authenticated studio users', function (): void {
|
||||
$now = now();
|
||||
|
||||
$user = new User([
|
||||
'username' => 'studio-user',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Studio User',
|
||||
'email' => 'studio@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$user->id = 1;
|
||||
|
||||
DB::table('tags')->insert([
|
||||
['id' => 1, 'name' => 'High Usage', 'slug' => 'high-usage', 'usage_count' => 500, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 2, 'name' => 'High Contrast', 'slug' => 'high-contrast', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 3, 'name' => 'Hidden Draft', 'slug' => 'hidden-draft', 'usage_count' => 900, 'is_active' => false, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('tag_interaction_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'high-contrast',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'hi',
|
||||
'clicks' => 30,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 11,
|
||||
'avg_position' => 1.1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'high-usage',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'hi',
|
||||
'clicks' => 3,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 2,
|
||||
'avg_position' => 2.8,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/studio/tags/search?q=hi');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
expect($data)->toHaveCount(2);
|
||||
expect(array_column($data, 'slug'))->toBe(['high-contrast', 'high-usage']);
|
||||
expect((int) $data[0]['recent_clicks'])->toBe(30);
|
||||
expect($data[0])->toHaveKeys(['id', 'name', 'slug', 'usage_count', 'recent_clicks']);
|
||||
expect($data[0])->not->toHaveKeys(['created_at', 'updated_at', 'is_active']);
|
||||
});
|
||||
219
tests/Unit/TagDiscoveryServiceTest.php
Normal file
219
tests/Unit/TagDiscoveryServiceTest.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Schema::dropIfExists('tag_interaction_daily_metrics');
|
||||
Schema::dropIfExists('artwork_tag');
|
||||
Schema::dropIfExists('artworks');
|
||||
Schema::dropIfExists('tags');
|
||||
|
||||
Schema::create('tags', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->unsignedInteger('usage_count')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('artworks', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('slug')->nullable();
|
||||
$table->boolean('is_public')->default(true);
|
||||
$table->boolean('is_approved')->default(true);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('artwork_tag', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('artwork_id');
|
||||
$table->unsignedBigInteger('tag_id');
|
||||
$table->string('source')->nullable();
|
||||
$table->unsignedInteger('confidence')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->date('metric_date');
|
||||
$table->string('surface', 32);
|
||||
$table->string('tag_slug', 120)->default('');
|
||||
$table->string('source_tag_slug', 120)->default('');
|
||||
$table->string('query', 120)->default('');
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->unsignedInteger('unique_users')->default(0);
|
||||
$table->unsignedInteger('unique_sessions')->default(0);
|
||||
$table->decimal('avg_position', 8, 2)->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps slug in related tag payload when transition metrics are joined', function (): void {
|
||||
$now = now();
|
||||
|
||||
DB::table('tags')->insert([
|
||||
['id' => 1, 'name' => 'Primary', 'slug' => 'primary', 'usage_count' => 100, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 2, 'name' => 'Beta', 'slug' => 'beta', 'usage_count' => 80, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 3, 'name' => 'Gamma', 'slug' => 'gamma', 'usage_count' => 60, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('artworks')->insert([
|
||||
['id' => 10, 'title' => 'One', 'slug' => 'one', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 11, 'title' => 'Two', 'slug' => 'two', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 12, 'title' => 'Three', 'slug' => 'three', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('artwork_tag')->insert([
|
||||
['artwork_id' => 10, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 10, 'tag_id' => 2, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 11, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 11, 'tag_id' => 2, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 12, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 12, 'tag_id' => 3, 'source' => 'user', 'confidence' => 100],
|
||||
]);
|
||||
|
||||
DB::table('tag_interaction_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'related_chip',
|
||||
'tag_slug' => 'beta',
|
||||
'source_tag_slug' => 'primary',
|
||||
'query' => '',
|
||||
'clicks' => 25,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 10,
|
||||
'avg_position' => 1.4,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'related_chip',
|
||||
'tag_slug' => 'gamma',
|
||||
'source_tag_slug' => 'primary',
|
||||
'query' => '',
|
||||
'clicks' => 4,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 3,
|
||||
'avg_position' => 2.2,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$primary = \App\Models\Tag::query()->findOrFail(1);
|
||||
|
||||
$relatedTags = app(TagDiscoveryService::class)->relatedTags($primary, 8);
|
||||
|
||||
expect($relatedTags)->toHaveCount(2);
|
||||
expect(isset($relatedTags[0]->slug))->toBeTrue();
|
||||
expect($relatedTags[0]->slug)->toBe('beta');
|
||||
expect((int) $relatedTags[0]->shared_artworks_count)->toBe(2);
|
||||
expect((int) $relatedTags[0]->transition_clicks)->toBe(25);
|
||||
expect(collect($relatedTags)->pluck('slug')->all())->toBe(['beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('orders featured tags by recent clicks before usage count', function (): void {
|
||||
$now = now();
|
||||
|
||||
DB::table('tags')->insert([
|
||||
['id' => 1, 'name' => 'Legacy Heavy', 'slug' => 'legacy-heavy', 'usage_count' => 900, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 2, 'name' => 'Momentum First', 'slug' => 'momentum-first', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 3, 'name' => 'Quiet', 'slug' => 'quiet', 'usage_count' => 80, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('tag_interaction_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'legacy-heavy',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'legacy',
|
||||
'clicks' => 8,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 4,
|
||||
'avg_position' => 2.5,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'momentum-first',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'momentum',
|
||||
'clicks' => 42,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 18,
|
||||
'avg_position' => 1.2,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$featured = app(TagDiscoveryService::class)->featuredTags(3);
|
||||
|
||||
expect($featured)->toHaveCount(3);
|
||||
expect(collect($featured)->pluck('slug')->all())->toBe(['momentum-first', 'legacy-heavy', 'quiet']);
|
||||
expect((int) $featured[0]->recent_clicks)->toBe(42);
|
||||
expect((int) $featured[1]->recent_clicks)->toBe(8);
|
||||
});
|
||||
|
||||
it('fills rising tags from usage fallback without duplicating featured tags', function (): void {
|
||||
$now = now();
|
||||
|
||||
DB::table('tags')->insert([
|
||||
['id' => 1, 'name' => 'Featured Momentum', 'slug' => 'featured-momentum', 'usage_count' => 800, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 2, 'name' => 'Rising Momentum', 'slug' => 'rising-momentum', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 3, 'name' => 'Fallback Alpha', 'slug' => 'fallback-alpha', 'usage_count' => 450, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 4, 'name' => 'Fallback Beta', 'slug' => 'fallback-beta', 'usage_count' => 300, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('tag_interaction_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'featured-momentum',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'featured',
|
||||
'clicks' => 50,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 20,
|
||||
'avg_position' => 1.1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'rising-momentum',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'rising',
|
||||
'clicks' => 12,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 6,
|
||||
'avg_position' => 1.8,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(TagDiscoveryService::class);
|
||||
$featured = $service->featuredTags(1);
|
||||
$rising = $service->risingTags($featured, 3);
|
||||
|
||||
expect(collect($featured)->pluck('slug')->all())->toBe(['featured-momentum']);
|
||||
expect(collect($rising)->pluck('slug')->all())->toBe(['rising-momentum', 'fallback-alpha', 'fallback-beta']);
|
||||
expect(collect($rising)->contains(fn ($tag) => $tag->slug === 'featured-momentum'))->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user