trypost/tests/Feature/Controllers/ChunkedUploadTest.php
Paulo Castellano 2da15df96c feat: introduce Account entity as billing owner and refactor architecture
- Create Account model as Cashier Billable entity (stripe, plan, subscription)
- Account owns workspaces and has an owner_id (User)
- User belongs to one Account via account_id
- Workspace belongs to Account via account_id, no longer has billing fields
- Remove Brand model entirely (workspaces serve as grouping)
- Rename brand_limit to workspace_limit in plans
- Workspace roles simplified: admin/member/viewer (owner via Account)
- Invites now belong to Account with workspaces JSON array
- Pennant features scope changed from Workspace to Account
- EnsureSubscribed middleware checks Account subscription
- All controllers updated: BillingController, OnboardingController,
  WorkspaceInviteController, SocialController, StripeEventListener
- Frontend: extract GoogleAuthButton component, create WorkspaceRole
  enum for type-safe role checks, fix all views for new architecture
- All 1101 tests passing
2026-04-14 22:22:04 -03:00

241 lines
7.2 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->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_123',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$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' which has no extension, so it should fail validation
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['file_name']);
});
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();
});