Upload beautify
This commit is contained in:
120
tests/Unit/Discovery/FeedOfflineEvaluationServiceTest.php
Normal file
120
tests/Unit/Discovery/FeedOfflineEvaluationServiceTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('evaluates objective metrics for an algo from feed_daily_metrics', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 8,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 7,
|
||||
'dwell_30_120' => 6,
|
||||
'dwell_120_plus' => 4,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate);
|
||||
|
||||
expect((string) $result['algo_version'])->toBe('clip-cosine-v1');
|
||||
expect((float) $result['ctr'])->toBe(0.2);
|
||||
expect((float) $result['save_rate'])->toBe(0.4);
|
||||
expect((float) $result['long_dwell_share'])->toBe(0.5);
|
||||
expect((float) $result['bounce_rate'])->toBe(0.15);
|
||||
expect((float) $result['objective_score'])->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('compares baseline vs candidate with delta and lift', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 6,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.3,
|
||||
'dwell_0_5' => 4,
|
||||
'dwell_5_30' => 8,
|
||||
'dwell_30_120' => 5,
|
||||
'dwell_120_plus' => 3,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v2',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 25,
|
||||
'saves' => 10,
|
||||
'ctr' => 0.25,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 8,
|
||||
'dwell_30_120' => 8,
|
||||
'dwell_120_plus' => 6,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$comparison = app(FeedOfflineEvaluationService::class)
|
||||
->compareBaselineCandidate('clip-cosine-v1', 'clip-cosine-v2', $metricDate, $metricDate);
|
||||
|
||||
expect((float) $comparison['delta']['objective_score'])->toBeGreaterThan(0.0);
|
||||
expect((float) $comparison['delta']['ctr'])->toBeGreaterThan(0.0);
|
||||
expect((float) $comparison['delta']['save_rate'])->toBeGreaterThan(0.0);
|
||||
});
|
||||
|
||||
it('treats save_rate as informational when configured', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
config()->set('discovery.evaluation.objective_weights', [
|
||||
'ctr' => 0.45,
|
||||
'save_rate' => 0.35,
|
||||
'long_dwell_share' => 0.25,
|
||||
'bounce_rate_penalty' => 0.15,
|
||||
]);
|
||||
config()->set('discovery.evaluation.save_rate_informational', true);
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 8,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 7,
|
||||
'dwell_30_120' => 6,
|
||||
'dwell_120_plus' => 4,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate);
|
||||
|
||||
expect((float) $result['save_rate'])->toBe(0.4);
|
||||
expect((float) $result['objective_score'])->toBe(0.226471);
|
||||
});
|
||||
76
tests/Unit/Discovery/PersonalizedFeedServiceTest.php
Normal file
76
tests/Unit/Discovery/PersonalizedFeedServiceTest.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use App\Services\Recommendations\PersonalizedFeedService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('regenerates recommendation cache with items and expiry', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
|
||||
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $artworkA->id, 'views' => 120, 'downloads' => 30, 'favorites' => 2, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
['artwork_id' => $artworkB->id, 'views' => 100, 'downloads' => 20, 'favorites' => 1, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
]);
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id, (string) config('discovery.algo_version'));
|
||||
|
||||
$cache = UserRecommendationCache::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('algo_version', (string) config('discovery.algo_version'))
|
||||
->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect($cache?->generated_at)->not->toBeNull();
|
||||
expect($cache?->expires_at)->not->toBeNull();
|
||||
|
||||
$items = (array) ($cache?->recommendations_json['items'] ?? []);
|
||||
expect(count($items))->toBeGreaterThan(0);
|
||||
expect((int) ($items[0]['artwork_id'] ?? 0))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses rollout gate g100 to select candidate algo version', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
config()->set('discovery.rollout.enabled', true);
|
||||
config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1');
|
||||
config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2');
|
||||
config()->set('discovery.rollout.active_gate', 'g100');
|
||||
config()->set('discovery.rollout.gates.g100.percentage', 100);
|
||||
config()->set('discovery.rollout.force_algo_version', '');
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id);
|
||||
|
||||
$cache = UserRecommendationCache::query()->where('user_id', $user->id)->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect((string) $cache?->algo_version)->toBe('clip-cosine-v2');
|
||||
});
|
||||
|
||||
it('forces rollback algo version when force toggle is set', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
config()->set('discovery.rollout.enabled', true);
|
||||
config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1');
|
||||
config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2');
|
||||
config()->set('discovery.rollout.active_gate', 'g100');
|
||||
config()->set('discovery.rollout.gates.g100.percentage', 100);
|
||||
config()->set('discovery.rollout.force_algo_version', 'clip-cosine-v1');
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id);
|
||||
|
||||
$cache = UserRecommendationCache::query()->where('user_id', $user->id)->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect((string) $cache?->algo_version)->toBe('clip-cosine-v1');
|
||||
});
|
||||
86
tests/Unit/Discovery/UserInterestProfileServiceTest.php
Normal file
86
tests/Unit/Discovery/UserInterestProfileServiceTest.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use App\Services\Recommendations\UserInterestProfileService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('applies recency decay and normalizes profile scores', function () {
|
||||
config()->set('discovery.decay.half_life_hours', 72);
|
||||
config()->set('discovery.weights.view', 1.0);
|
||||
|
||||
$service = app(UserInterestProfileService::class);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital artworks',
|
||||
]);
|
||||
|
||||
$categoryA = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Sci-Fi',
|
||||
'slug' => 'sci-fi',
|
||||
'description' => 'Sci-Fi category',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$categoryB = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Fantasy',
|
||||
'slug' => 'fantasy',
|
||||
'description' => 'Fantasy category',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$artworkA = Artwork::factory()->create();
|
||||
$artworkB = Artwork::factory()->create();
|
||||
|
||||
$t0 = CarbonImmutable::parse('2026-02-14 00:00:00');
|
||||
|
||||
$service->applyEvent(
|
||||
userId: $user->id,
|
||||
eventType: 'view',
|
||||
artworkId: $artworkA->id,
|
||||
categoryId: $categoryA->id,
|
||||
occurredAt: $t0,
|
||||
eventId: '11111111-1111-1111-1111-111111111111',
|
||||
algoVersion: 'clip-cosine-v1'
|
||||
);
|
||||
|
||||
$service->applyEvent(
|
||||
userId: $user->id,
|
||||
eventType: 'view',
|
||||
artworkId: $artworkB->id,
|
||||
categoryId: $categoryB->id,
|
||||
occurredAt: $t0->addHours(72),
|
||||
eventId: '22222222-2222-2222-2222-222222222222',
|
||||
algoVersion: 'clip-cosine-v1'
|
||||
);
|
||||
|
||||
$profile = \App\Models\UserInterestProfile::query()->where('user_id', $user->id)->firstOrFail();
|
||||
|
||||
expect((int) $profile->event_count)->toBe(2);
|
||||
|
||||
$normalized = (array) $profile->normalized_scores_json;
|
||||
|
||||
expect($normalized)->toHaveKey('category:' . $categoryA->id);
|
||||
expect($normalized)->toHaveKey('category:' . $categoryB->id);
|
||||
|
||||
expect((float) $normalized['category:' . $categoryA->id])->toBeGreaterThan(0.30)->toBeLessThan(0.35);
|
||||
expect((float) $normalized['category:' . $categoryB->id])->toBeGreaterThan(0.65)->toBeLessThan(0.70);
|
||||
});
|
||||
168
tests/Unit/Uploads/ArchiveInspectorServiceTest.php
Normal file
168
tests/Unit/Uploads/ArchiveInspectorServiceTest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Uploads;
|
||||
|
||||
use App\Uploads\Services\ArchiveInspectorService;
|
||||
use App\Uploads\Services\InspectionResult;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ArchiveInspectorServiceTest extends TestCase
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private array $tempFiles = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->tempFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_rejects_zip_slip_path(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'../evil.txt' => 'x',
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('path traversal', (string) $result->reason);
|
||||
}
|
||||
|
||||
public function test_rejects_symlink_entries(): void
|
||||
{
|
||||
$archive = $this->makeZipWithCallback([
|
||||
'safe/file.txt' => 'ok',
|
||||
'safe/link' => 'target',
|
||||
], function (ZipArchive $zip, string $entryName): void {
|
||||
if ($entryName === 'safe/link') {
|
||||
$zip->setExternalAttributesName($entryName, ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
||||
}
|
||||
});
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('symlink', strtolower((string) $result->reason));
|
||||
}
|
||||
|
||||
public function test_rejects_deep_nesting(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'a/b/c/d/e/f/file.txt' => 'too deep',
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('depth', strtolower((string) $result->reason));
|
||||
}
|
||||
|
||||
public function test_rejects_too_many_files(): void
|
||||
{
|
||||
$entries = [];
|
||||
for ($index = 0; $index < 5001; $index++) {
|
||||
$entries['f' . $index . '.txt'] = 'x';
|
||||
}
|
||||
|
||||
$archive = $this->makeZip($entries);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('5000', (string) $result->reason);
|
||||
}
|
||||
|
||||
public function test_rejects_executable_extensions(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'skins/readme.txt' => 'ok',
|
||||
'skins/run.exe' => 'MZ',
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('blocked', strtolower((string) $result->reason));
|
||||
}
|
||||
|
||||
public function test_rejects_zip_bomb_ratio(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'payload.txt' => str_repeat('A', 6 * 1024 * 1024),
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('ratio', strtolower((string) $result->reason));
|
||||
}
|
||||
|
||||
public function test_valid_archive_passes(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'skins/theme/readme.txt' => 'safe',
|
||||
'skins/theme/colors.ini' => 'accent=blue',
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertInstanceOf(InspectionResult::class, $result);
|
||||
$this->assertTrue($result->valid);
|
||||
$this->assertNull($result->reason);
|
||||
$this->assertIsArray($result->stats);
|
||||
$this->assertArrayHasKey('files', $result->stats);
|
||||
$this->assertArrayHasKey('depth', $result->stats);
|
||||
$this->assertArrayHasKey('size', $result->stats);
|
||||
$this->assertArrayHasKey('ratio', $result->stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function makeZip(array $entries): string
|
||||
{
|
||||
return $this->makeZipWithCallback($entries, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @param (callable(ZipArchive,string):void)|null $entryCallback
|
||||
*/
|
||||
private function makeZipWithCallback(array $entries, ?callable $entryCallback): string
|
||||
{
|
||||
if (! class_exists(ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive extension is required.');
|
||||
}
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'sb_zip_');
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('Unable to create temporary zip path.');
|
||||
}
|
||||
|
||||
$this->tempFiles[] = $path;
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) {
|
||||
throw new \RuntimeException('Unable to open temporary zip for writing.');
|
||||
}
|
||||
|
||||
foreach ($entries as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
|
||||
if ($entryCallback !== null) {
|
||||
$entryCallback($zip, $name);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
139
tests/Unit/Uploads/CleanupServiceTest.php
Normal file
139
tests/Unit/Uploads/CleanupServiceTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Uploads;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Uploads\Services\CleanupService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CleanupServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function insertUploadRow(array $overrides = []): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
|
||||
$defaults = [
|
||||
'id' => $id,
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'title' => null,
|
||||
'slug' => null,
|
||||
'category_id' => null,
|
||||
'description' => null,
|
||||
'tags' => null,
|
||||
'license' => null,
|
||||
'nsfw' => false,
|
||||
'is_scanned' => false,
|
||||
'has_tags' => false,
|
||||
'preview_path' => null,
|
||||
'published_at' => null,
|
||||
'final_path' => null,
|
||||
'expires_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
DB::table('uploads')->insert(array_merge($defaults, $overrides));
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_deletes_expired_draft_uploads_and_returns_count(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'draft',
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertFalse(DB::table('uploads')->where('id', $uploadId)->exists());
|
||||
}
|
||||
|
||||
public function test_keeps_active_drafts_untouched(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'draft',
|
||||
'expires_at' => now()->addDay(),
|
||||
'updated_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
|
||||
|
||||
$this->assertSame(0, $deleted);
|
||||
$this->assertTrue(DB::table('uploads')->where('id', $uploadId)->exists());
|
||||
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json"));
|
||||
}
|
||||
|
||||
public function test_removes_temp_folder_when_deleting_stale_drafts(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'draft',
|
||||
'updated_at' => now()->subHours(25),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.bin", 'x');
|
||||
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin"));
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertFalse(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin"));
|
||||
}
|
||||
|
||||
public function test_enforces_hard_cleanup_limit_of_100_per_run(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
for ($index = 0; $index < 120; $index++) {
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'draft',
|
||||
'updated_at' => now()->subHours(30),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
}
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts(999);
|
||||
|
||||
$this->assertSame(100, $deleted);
|
||||
$this->assertSame(20, DB::table('uploads')->count());
|
||||
}
|
||||
|
||||
public function test_never_deletes_published_uploads(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'published',
|
||||
'updated_at' => now()->subDays(5),
|
||||
'published_at' => now()->subDays(4),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
|
||||
|
||||
$this->assertSame(0, $deleted);
|
||||
$this->assertTrue(DB::table('uploads')->where('id', $uploadId)->where('status', 'published')->exists());
|
||||
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json"));
|
||||
}
|
||||
}
|
||||
102
tests/Unit/Uploads/PublishServiceTest.php
Normal file
102
tests/Unit/Uploads/PublishServiceTest.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Uploads\Services\PublishService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function createCategoryForPublishTests(): int
|
||||
{
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Winamp',
|
||||
'slug' => 'winamp-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('rejects publish when user is not owner', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishTests();
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $owner->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'title' => 'City Lights',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(PublishService::class);
|
||||
|
||||
expect(fn () => $service->publish($uploadId, $other))
|
||||
->toThrow(RuntimeException::class, 'You do not own this upload.');
|
||||
});
|
||||
|
||||
it('rejects archive publish without screenshots', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishTests();
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $owner->id,
|
||||
'type' => 'archive',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'title' => 'Skin Pack',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/pack.zip",
|
||||
'type' => 'main',
|
||||
'hash' => 'aabbccddeeff0011',
|
||||
'size' => 1024,
|
||||
'mime' => 'application/zip',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(PublishService::class);
|
||||
|
||||
expect(fn () => $service->publish($uploadId, $owner))
|
||||
->toThrow(RuntimeException::class, 'Archive uploads require at least one screenshot.');
|
||||
});
|
||||
130
tests/Unit/Uploads/UploadDraftServiceTest.php
Normal file
130
tests/Unit/Uploads/UploadDraftServiceTest.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Uploads;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use App\Services\Upload\UploadDraftService;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Carbon\Carbon;
|
||||
use App\Models\User;
|
||||
|
||||
class UploadDraftServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected UploadDraftService $service;
|
||||
protected User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Use fake storage so we don't touch the real filesystem
|
||||
Storage::fake('local');
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
// Provide a dummy clamav scanner binding so any scanning calls are mocked
|
||||
$this->app->instance('clamav', new class {
|
||||
public function scan(string $path): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
$filesystem = $this->app->make(FilesystemManager::class);
|
||||
$this->service = new UploadDraftService($filesystem, 'local');
|
||||
}
|
||||
|
||||
public function test_createDraft_creates_directory_and_writes_meta()
|
||||
{
|
||||
$result = $this->service->createDraft(['title' => 'Test Draft', 'user_id' => $this->user->id, 'type' => 'image']);
|
||||
|
||||
$this->assertArrayHasKey('id', $result);
|
||||
$id = $result['id'];
|
||||
|
||||
Storage::disk('local')->assertExists("tmp/drafts/{$id}");
|
||||
Storage::disk('local')->assertExists("tmp/drafts/{$id}/meta.json");
|
||||
|
||||
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
|
||||
$this->assertSame('Test Draft', $meta['title']);
|
||||
$this->assertSame($id, $meta['id']);
|
||||
}
|
||||
|
||||
public function test_storeMainFile_saves_file_and_updates_meta()
|
||||
{
|
||||
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
|
||||
$id = $draft['id'];
|
||||
|
||||
$file = UploadedFile::fake()->create('song.mp3', 1500, 'audio/mpeg');
|
||||
|
||||
$info = $this->service->storeMainFile($id, $file);
|
||||
|
||||
$this->assertArrayHasKey('path', $info);
|
||||
Storage::disk('local')->assertExists($info['path']);
|
||||
|
||||
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
|
||||
$this->assertArrayHasKey('main_file', $meta);
|
||||
$this->assertSame($info['hash'], $meta['main_file']['hash']);
|
||||
}
|
||||
|
||||
public function test_storeScreenshot_saves_file_and_appends_meta()
|
||||
{
|
||||
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
|
||||
$id = $draft['id'];
|
||||
|
||||
$img = UploadedFile::fake()->image('thumb.jpg', 640, 480);
|
||||
|
||||
$info = $this->service->storeScreenshot($id, $img);
|
||||
|
||||
$this->assertArrayHasKey('path', $info);
|
||||
Storage::disk('local')->assertExists($info['path']);
|
||||
|
||||
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
|
||||
$this->assertArrayHasKey('screenshots', $meta);
|
||||
$this->assertCount(1, $meta['screenshots']);
|
||||
$this->assertSame($info['hash'], $meta['screenshots'][0]['hash']);
|
||||
}
|
||||
|
||||
public function test_calculateHash_for_local_file_and_storage_path()
|
||||
{
|
||||
$file = UploadedFile::fake()->create('doc.pdf', 10);
|
||||
$realPath = $file->getRealPath();
|
||||
|
||||
$expected = hash_file('sha256', $realPath);
|
||||
$this->assertSame($expected, $this->service->calculateHash($realPath));
|
||||
|
||||
// Store into drafts and calculate by storage path
|
||||
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
|
||||
$id = $draft['id'];
|
||||
$info = $this->service->storeMainFile($id, $file);
|
||||
|
||||
$storageHash = $this->service->calculateHash($info['path']);
|
||||
$storedContents = Storage::disk('local')->get($info['path']);
|
||||
$this->assertSame(hash('sha256', $storedContents), $storageHash);
|
||||
}
|
||||
|
||||
public function test_setExpiration_writes_expires_at_in_meta()
|
||||
{
|
||||
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
|
||||
$id = $draft['id'];
|
||||
|
||||
$when = Carbon::now()->addDays(3);
|
||||
$ok = $this->service->setExpiration($id, $when);
|
||||
$this->assertTrue($ok);
|
||||
|
||||
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
|
||||
$this->assertArrayHasKey('expires_at', $meta);
|
||||
$this->assertSame($when->toISOString(), $meta['expires_at']);
|
||||
}
|
||||
|
||||
public function test_calculateHash_throws_for_missing_file()
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->service->calculateHash('this/path/does/not/exist');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user