trypost/tests/Feature/Controllers/ChunkedUploadTest.php
Paulo Castellano 56b8c92e72 refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals

Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController

Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props

All 710 tests passing.
2026-03-30 00:20:43 -03:00

241 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Models\Media;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
$this->user = User::factory()->create([
'setup' => Setup::Completed,
]);
$this->user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_123',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
'quantity' => 1,
]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('chunked upload completes with single chunk', function () {
$content = str_repeat('x', 1000);
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'test-video.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$content,
);
$response->assertSuccessful();
$response->assertJson(['done' => true]);
$response->assertJsonStructure(['done', 'id', 'group_id', 'url', 'type', 'original_filename']);
expect(Media::count())->toBe(1);
});
test('chunked upload reports progress on intermediate chunks', function () {
$chunkSize = 500;
$totalSize = 1000;
$firstChunk = str_repeat('a', $chunkSize);
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => "bytes 0-499/{$totalSize}",
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'test-video.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$firstChunk,
);
$response->assertSuccessful();
$response->assertJson([
'done' => false,
'progress' => 50,
]);
expect(Media::count())->toBe(0);
});
test('chunked upload completes with multiple chunks', function () {
$totalSize = 1000;
$firstChunk = str_repeat('a', 500);
$secondChunk = str_repeat('b', 500);
// First chunk
$this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => "bytes 0-499/{$totalSize}",
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'multi-chunk.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$firstChunk,
)->assertJson(['done' => false]);
// Second chunk (last)
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => "bytes 500-999/{$totalSize}",
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'multi-chunk.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$secondChunk,
);
$response->assertSuccessful();
$response->assertJson(['done' => true]);
expect(Media::count())->toBe(1);
});
test('chunked upload fails without content range header', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_FILE_NAME' => 'test.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
'content',
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('content_range');
});
test('chunked upload fails with invalid model type', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_MODEL' => 'invalid',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_FILE_NAME' => 'test.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('x', 1000),
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('model');
});
test('chunked upload fails without model header', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_FILE_NAME' => 'test.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('x', 1000),
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('model');
});
test('chunked upload fails without file name header', function () {
$response = $this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
str_repeat('x', 1000),
);
// X-File-Name defaults to 'upload' so it should still work
$response->assertSuccessful();
});
test('chunked upload cleans up temp file after completion', function () {
$content = str_repeat('x', 1000);
$identifier = md5('cleanup-test.mp4'.'1000');
$tempPath = storage_path("app/private/chunks/{$identifier}");
$this->actingAs($this->user)->call(
'POST',
route('app.medias.store-chunked'),
[],
[],
[],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
'HTTP_X_MODEL' => 'workspace',
'HTTP_X_MODEL_ID' => $this->workspace->id,
'HTTP_X_COLLECTION' => 'default',
'HTTP_X_FILE_NAME' => 'cleanup-test.mp4',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$content,
)->assertJson(['done' => true]);
expect(file_exists($tempPath))->toBeFalse();
});