Wire admin studio SSR and search infrastructure
This commit is contained in:
61
tests/Feature/LegacyArtworkPhotoRouteTest.php
Normal file
61
tests/Feature/LegacyArtworkPhotoRouteTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
|
||||
function encodeLegacyPhotoId(int $value, string $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'): string
|
||||
{
|
||||
if ($value === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
$encoded = '';
|
||||
|
||||
while ($value > 0) {
|
||||
$encoded = $chars[$value % 62] . $encoded;
|
||||
$value = intdiv($value, 62);
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
it('redirects legacy photo thumbnail urls to the mapped CDN thumbnail size', function (): void {
|
||||
config([
|
||||
'cdn.files_url' => 'https://cdn.example.test',
|
||||
'uploads.object_storage.prefix' => 'artworks',
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => 'aabbccddeeff0011',
|
||||
'thumb_ext' => 'webp',
|
||||
'file_ext' => 'jpg',
|
||||
'file_path' => '',
|
||||
]);
|
||||
|
||||
$response = $this->get('/photo/' . encodeLegacyPhotoId($artwork->id) . '_6.png');
|
||||
|
||||
$response
|
||||
->assertStatus(301)
|
||||
->assertRedirect('https://cdn.example.test/artworks/md/aa/bb/aabbccddeeff0011.webp');
|
||||
});
|
||||
|
||||
it('redirects legacy full-size photo urls to the original CDN asset', function (): void {
|
||||
config([
|
||||
'cdn.files_url' => 'https://cdn.example.test',
|
||||
'uploads.object_storage.prefix' => 'artworks',
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => '1122334455667788',
|
||||
'thumb_ext' => 'webp',
|
||||
'file_ext' => 'png',
|
||||
'file_path' => '',
|
||||
]);
|
||||
|
||||
$response = $this->get('/photo/' . encodeLegacyPhotoId($artwork->id) . '_7.png');
|
||||
|
||||
$response
|
||||
->assertStatus(301)
|
||||
->assertRedirect('https://cdn.example.test/artworks/original/11/22/1122334455667788.png');
|
||||
});
|
||||
@@ -428,3 +428,38 @@ it('produces cosine-normalized weights in pair builder', function () {
|
||||
// S_beh = 2 / sqrt(2 * 2) = 2 / 2 = 1.0
|
||||
expect($pair->weight)->toBe(1.0);
|
||||
});
|
||||
|
||||
it('accumulates pair weights across small user chunks and rebuilds cleanly on rerun', function () {
|
||||
$userA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
$art1 = createPublicArtwork();
|
||||
$art2 = createPublicArtwork();
|
||||
|
||||
DB::table('artwork_favourites')->insert([
|
||||
['user_id' => $userA->id, 'artwork_id' => $art1->id, 'created_at' => now()->subMinute(), 'updated_at' => now()->subMinute()],
|
||||
['user_id' => $userA->id, 'artwork_id' => $art2->id, 'created_at' => now()->subMinute(), 'updated_at' => now()->subMinute()],
|
||||
['user_id' => $userB->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $userB->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$job = new RecBuildItemPairsFromFavouritesJob(1);
|
||||
$job->handle();
|
||||
|
||||
$pair = RecItemPair::query()
|
||||
->where('a_artwork_id', min($art1->id, $art2->id))
|
||||
->where('b_artwork_id', max($art1->id, $art2->id))
|
||||
->first();
|
||||
|
||||
expect($pair)->not->toBeNull();
|
||||
expect($pair?->weight)->toBe(1.0);
|
||||
|
||||
$job->handle();
|
||||
|
||||
$rebuiltPair = RecItemPair::query()
|
||||
->where('a_artwork_id', min($art1->id, $art2->id))
|
||||
->where('b_artwork_id', max($art1->id, $art2->id))
|
||||
->first();
|
||||
|
||||
expect($rebuiltPair)->not->toBeNull();
|
||||
expect($rebuiltPair?->weight)->toBe(1.0);
|
||||
});
|
||||
|
||||
11
tests/Feature/SearchCanonicalizationTest.php
Normal file
11
tests/Feature/SearchCanonicalizationTest.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('redirects malformed public search query strings to a canonical url', function (): void {
|
||||
$response = $this->get('/search?amp;id=&sort=&filter=&group=all&id=&page=0&page=1&q=winstep?page=1?page=1?page=1&sort=&sort=category?page=1&txtfilter=');
|
||||
|
||||
$response
|
||||
->assertStatus(301)
|
||||
->assertRedirect('/search?q=winstep');
|
||||
});
|
||||
85
tests/Feature/TodayDownloadsPageTest.php
Normal file
85
tests/Feature/TodayDownloadsPageTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('falls back to the latest 1000 downloads when today has no downloads', function () {
|
||||
$newerArtwork = Artwork::factory()->create([
|
||||
'title' => 'Latest Window Artwork',
|
||||
'published_at' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
$olderArtwork = Artwork::factory()->create([
|
||||
'title' => 'Older Window Artwork',
|
||||
'published_at' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
DB::table('artwork_downloads')->insert([
|
||||
[
|
||||
'artwork_id' => $newerArtwork->id,
|
||||
'user_id' => null,
|
||||
'ip' => inet_pton('127.0.0.1'),
|
||||
'user_agent' => 'Pest',
|
||||
'created_at' => now()->subDay()->setTime(12, 0, 0),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $newerArtwork->id,
|
||||
'user_id' => null,
|
||||
'ip' => inet_pton('127.0.0.1'),
|
||||
'user_agent' => 'Pest',
|
||||
'created_at' => now()->subDays(2)->setTime(12, 0, 0),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $olderArtwork->id,
|
||||
'user_id' => null,
|
||||
'ip' => inet_pton('127.0.0.1'),
|
||||
'user_agent' => 'Pest',
|
||||
'created_at' => now()->subDays(3)->setTime(12, 0, 0),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get('/downloads/today')
|
||||
->assertOk()
|
||||
->assertSee('Latest Window Artwork', false)
|
||||
->assertSee('Older Window Artwork', false)
|
||||
->assertSee('Latest 1000 downloads', false)
|
||||
->assertDontSee('No download activity is available yet.', false);
|
||||
});
|
||||
|
||||
it('prefers real today downloads over the fallback window', function () {
|
||||
$todayArtwork = Artwork::factory()->create([
|
||||
'title' => 'Today Download Artwork',
|
||||
'published_at' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
$fallbackArtwork = Artwork::factory()->create([
|
||||
'title' => 'Fallback Only Artwork',
|
||||
'published_at' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
DB::table('artwork_downloads')->insert([
|
||||
[
|
||||
'artwork_id' => $todayArtwork->id,
|
||||
'user_id' => null,
|
||||
'ip' => inet_pton('127.0.0.1'),
|
||||
'user_agent' => 'Pest',
|
||||
'created_at' => now()->setTime(11, 0, 0),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $fallbackArtwork->id,
|
||||
'user_id' => null,
|
||||
'ip' => inet_pton('127.0.0.1'),
|
||||
'user_agent' => 'Pest',
|
||||
'created_at' => now()->subDays(2)->setTime(12, 0, 0),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get('/downloads/today')
|
||||
->assertOk()
|
||||
->assertSee('Today Download Artwork', false)
|
||||
->assertSee('Live today', false)
|
||||
->assertDontSee('Fallback Only Artwork', false)
|
||||
->assertDontSee('Latest 1000 downloads', false);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ beforeEach(function (): void {
|
||||
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
|
||||
config()->set('vision.vector_gateway.api_key', 'test-key');
|
||||
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
|
||||
config()->set('vision.vector_gateway.search_file_endpoint', '/vectors/search/file');
|
||||
config()->set('cdn.files_url', 'https://files.skinbase.org');
|
||||
config()->set('app.url', 'https://skinbase.test');
|
||||
Storage::fake('public');
|
||||
@@ -44,7 +45,8 @@ it('returns AI similar artworks for a public artwork', function (): void {
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.klevze.net/vectors/search' => Http::response([
|
||||
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
||||
'https://vision.klevze.net/vectors/search/file' => Http::response([
|
||||
'results' => [
|
||||
['id' => $source->id, 'score' => 1.0],
|
||||
['id' => $match->id, 'score' => 0.91234],
|
||||
@@ -61,6 +63,43 @@ it('returns AI similar artworks for a public artwork', function (): void {
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
it('falls back to URL search when the file vector endpoint fails for similar-ai', function (): void {
|
||||
$source = Artwork::factory()->create([
|
||||
'title' => 'Fallback source artwork',
|
||||
'hash' => 'ffeeddccbbaa',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$match = Artwork::factory()->create([
|
||||
'title' => 'Fallback match',
|
||||
'hash' => '998877665544',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
||||
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'missing endpoint'], 404),
|
||||
'https://vision.klevze.net/vectors/search' => Http::response([
|
||||
'results' => [
|
||||
['id' => $source->id, 'score' => 1.0],
|
||||
['id' => $match->id, 'score' => 0.90123],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
getJson('/api/art/' . $source->id . '/similar-ai')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.id', $match->id)
|
||||
->assertJsonPath('meta.artwork_id', $source->id)
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
it('returns 404 for missing similar-ai source artwork', function (): void {
|
||||
getJson('/api/art/999999/similar-ai')
|
||||
->assertStatus(404)
|
||||
@@ -79,7 +118,7 @@ it('searches by uploaded image through the vector gateway', function (): void {
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.klevze.net/vectors/search' => Http::response([
|
||||
'https://vision.klevze.net/vectors/search/file' => Http::response([
|
||||
'results' => [
|
||||
['id' => $match->id, 'score' => 0.88765],
|
||||
],
|
||||
@@ -99,12 +138,7 @@ it('searches by uploaded image through the vector gateway', function (): void {
|
||||
->assertJsonPath('meta.limit', 12);
|
||||
|
||||
Http::assertSent(function ($request): bool {
|
||||
$payload = json_decode($request->body(), true);
|
||||
|
||||
return $request->url() === 'https://vision.klevze.net/vectors/search'
|
||||
&& $request->hasHeader('X-API-Key', 'test-key')
|
||||
&& is_array($payload)
|
||||
&& str_contains((string) ($payload['url'] ?? ''), '/storage/ai-search/tmp/')
|
||||
&& ($payload['limit'] ?? null) === 12;
|
||||
return $request->url() === 'https://vision.klevze.net/vectors/search/file'
|
||||
&& $request->hasHeader('X-API-Key', 'test-key');
|
||||
});
|
||||
});
|
||||
223
tests/Unit/ForumRestrictedCategoryAccessTest.php
Normal file
223
tests/Unit/ForumRestrictedCategoryAccessTest.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->withoutMiddleware(HandleInertiaRequests::class);
|
||||
|
||||
createForumRestrictedCategoryAccessSchema();
|
||||
|
||||
config()->set('forum.category_role_access', [
|
||||
'administrators-and-moderators-forum' => ['admin', 'manager'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides restricted forum categories from guests and regular users', function () {
|
||||
seedForumAccessBoards();
|
||||
|
||||
$this->get(route('forum.index'))
|
||||
->assertOk()
|
||||
->assertDontSee('Administrators and Moderators Forum')
|
||||
->assertSee('Public Forum');
|
||||
|
||||
$member = makeForumAccessUser('member-role', 'user');
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(route('forum.index'))
|
||||
->assertOk()
|
||||
->assertDontSee('Administrators and Moderators Forum')
|
||||
->assertSee('Public Forum');
|
||||
|
||||
$this->get(route('forum.category.show', ['categorySlug' => 'administrators-and-moderators-forum']))->assertNotFound();
|
||||
$this->get(route('forum.board.show', ['boardSlug' => 'administrators-and-moderators-forum']))->assertNotFound();
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(route('forum.category.show', ['categorySlug' => 'administrators-and-moderators-forum']))
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(route('forum.board.show', ['boardSlug' => 'administrators-and-moderators-forum']))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows admin and manager roles to access restricted forum categories', function () {
|
||||
seedForumAccessBoards();
|
||||
|
||||
$admin = makeForumAccessUser('admin-role', 'admin');
|
||||
$manager = makeForumAccessUser('manager-role', 'manager');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('forum.index'))
|
||||
->assertOk()
|
||||
->assertSee('Administrators and Moderators Forum');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('forum.category.show', ['categorySlug' => 'administrators-and-moderators-forum']))
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('forum.board.show', ['boardSlug' => 'administrators-and-moderators-forum']))
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($manager)
|
||||
->get(route('forum.index'))
|
||||
->assertOk()
|
||||
->assertSee('Administrators and Moderators Forum');
|
||||
|
||||
$this->actingAs($manager)
|
||||
->get(route('forum.category.show', ['categorySlug' => 'administrators-and-moderators-forum']))
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($manager)
|
||||
->get(route('forum.board.show', ['boardSlug' => 'administrators-and-moderators-forum']))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
function createForumRestrictedCategoryAccessSchema(): void
|
||||
{
|
||||
foreach (['forum_posts', 'forum_topics', 'forum_boards', 'forum_categories', 'user_profiles', 'users'] as $table) {
|
||||
Schema::dropIfExists($table);
|
||||
}
|
||||
|
||||
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('user_profiles', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('user_id')->primary();
|
||||
$table->string('avatar_hash')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_categories', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedBigInteger('parent_id')->nullable();
|
||||
$table->integer('position')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_boards', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('category_id')->constrained('forum_categories')->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('legacy_category_id')->nullable();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->integer('position')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('is_read_only')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_topics', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('board_id')->constrained('forum_boards')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->unsignedBigInteger('artwork_id')->nullable();
|
||||
$table->unsignedBigInteger('legacy_thread_id')->nullable();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->unsignedInteger('views')->default(0);
|
||||
$table->unsignedInteger('replies_count')->default(0);
|
||||
$table->boolean('is_pinned')->default(false);
|
||||
$table->boolean('is_locked')->default(false);
|
||||
$table->boolean('is_deleted')->default(false);
|
||||
$table->unsignedBigInteger('last_post_id')->nullable();
|
||||
$table->timestamp('last_post_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_posts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('thread_id')->nullable();
|
||||
$table->foreignId('topic_id')->nullable()->constrained('forum_topics')->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('content')->nullable();
|
||||
$table->boolean('is_edited')->default(false);
|
||||
$table->timestamp('edited_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
function seedForumAccessBoards(): array
|
||||
{
|
||||
$restrictedCategory = ForumCategory::query()->create([
|
||||
'title' => 'Administrators and Moderators Forum',
|
||||
'slug' => 'administrators-and-moderators-forum',
|
||||
'description' => 'Restricted board.',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$publicCategory = ForumCategory::query()->create([
|
||||
'title' => 'Public Forum',
|
||||
'slug' => 'public-forum',
|
||||
'description' => 'Visible to everyone.',
|
||||
'is_active' => true,
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$restrictedBoard = ForumBoard::query()->create([
|
||||
'category_id' => $restrictedCategory->id,
|
||||
'title' => 'Administrators and Moderators Forum',
|
||||
'slug' => 'administrators-and-moderators-forum',
|
||||
'description' => 'Restricted board.',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$publicBoard = ForumBoard::query()->create([
|
||||
'category_id' => $publicCategory->id,
|
||||
'title' => 'Public Forum',
|
||||
'slug' => 'public-forum',
|
||||
'description' => 'Visible to everyone.',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
return [$restrictedCategory, $publicCategory, $restrictedBoard, $publicBoard];
|
||||
}
|
||||
|
||||
function makeForumAccessUser(string $username, string $role): User
|
||||
{
|
||||
return User::query()->create([
|
||||
'username' => $username,
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => ucfirst($role) . ' User',
|
||||
'email' => $username . '@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
'role' => $role,
|
||||
]);
|
||||
}
|
||||
@@ -30,4 +30,14 @@ it('normalizes JSON string structured data schemas', function () {
|
||||
->toHaveCount(1)
|
||||
->and($seo['json_ld'][0]['@context'] ?? null)->toBe('https://schema.org')
|
||||
->and($seo['json_ld'][0]['@type'] ?? null)->toBe('CollectionPage');
|
||||
});
|
||||
|
||||
it('normalizes canonical page urls to the configured public app domain', function () {
|
||||
config()->set('app.url', 'https://skinbase.org');
|
||||
|
||||
$seo = SeoDataBuilder::fromArray([
|
||||
'canonical' => 'https://thumb.skinbase.top/search?q=winstep',
|
||||
])->build()->toArray();
|
||||
|
||||
expect($seo['canonical'] ?? null)->toBe('https://skinbase.org/search?q=winstep');
|
||||
});
|
||||
Reference in New Issue
Block a user