Add Fortify auth, Flux dark mode, demo seed, and Docker hot reload.

Protect recordings per user, seed a demo login on container start, and bind-mount the app with Vite HMR for local Compose development.
This commit is contained in:
ben
2026-08-12 18:38:53 +02:00
parent accb721811
commit bfcfc12f58
78 changed files with 3942 additions and 186 deletions
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_from_recordings_to_login(): void
{
$this->get(route('recordings.index'))
->assertRedirect(route('login'));
}
public function test_login_page_is_shown(): void
{
$this->get(route('login'))
->assertOk()
->assertSee('Log in');
}
public function test_register_page_is_shown(): void
{
$this->get(route('register'))
->assertOk()
->assertSee('Create an account');
}
public function test_users_can_register_and_are_authenticated(): void
{
$response = $this->post(route('register.store'), [
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertRedirect(route('recordings.index'));
$this->assertAuthenticated();
$this->assertDatabaseHas('users', ['email' => 'ada@example.com']);
}
public function test_users_can_log_in(): void
{
$user = User::factory()->create([
'email' => 'ben@example.com',
'password' => 'password',
]);
$response = $this->post(route('login.store'), [
'email' => 'ben@example.com',
'password' => 'password',
]);
$response->assertRedirect(route('recordings.index'));
$this->assertAuthenticatedAs($user);
}
public function test_first_registered_user_claims_orphaned_recordings(): void
{
Recording::query()->create([
'title' => 'Orphan',
'original_filename' => 'orphan.mp3',
'file_path' => 'recordings/orphan.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
'user_id' => null,
]);
$this->post(route('register.store'), [
'name' => 'First User',
'email' => 'first@example.com',
'password' => 'password',
'password_confirmation' => 'password',
])->assertRedirect(route('recordings.index'));
$user = User::query()->where('email', 'first@example.com')->first();
$this->assertNotNull($user);
$this->assertSame($user->id, Recording::query()->first()->user_id);
}
public function test_second_registered_user_does_not_claim_orphans(): void
{
User::factory()->create();
Recording::query()->create([
'title' => 'Still orphan',
'original_filename' => 'still.mp3',
'file_path' => 'recordings/still.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
'user_id' => null,
]);
$this->post(route('register.store'), [
'name' => 'Second User',
'email' => 'second@example.com',
'password' => 'password',
'password_confirmation' => 'password',
])->assertRedirect(route('recordings.index'));
$this->assertNull(Recording::query()->where('title', 'Still orphan')->value('user_id'));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class DemoUserSeederTest extends TestCase
{
use RefreshDatabase;
public function test_seeder_creates_demo_user(): void
{
$this->seed(DatabaseSeeder::class);
$user = User::query()->where('email', 'demo@example.com')->first();
$this->assertNotNull($user);
$this->assertSame('Demo User', $user->name);
$this->assertTrue(Hash::check('password', $user->password));
}
public function test_seeder_is_idempotent(): void
{
$this->seed(DatabaseSeeder::class);
$this->seed(DatabaseSeeder::class);
$this->assertSame(1, User::query()->where('email', 'demo@example.com')->count());
}
public function test_seeder_assigns_orphaned_recordings_to_demo_user(): void
{
Recording::query()->create([
'title' => 'Orphan',
'original_filename' => 'orphan.mp3',
'file_path' => 'recordings/orphan.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
'user_id' => null,
]);
$this->seed(DatabaseSeeder::class);
$user = User::query()->where('email', 'demo@example.com')->first();
$this->assertNotNull($user);
$this->assertSame($user->id, Recording::query()->where('title', 'Orphan')->value('user_id'));
}
}
+11
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Models\User;
use App\Services\DiskSpaceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
@@ -11,6 +12,16 @@ class DiskSpaceTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_disk_space_service_returns_snapshot_for_storage_path(): void
{
Cache::flush();
+5 -3
View File
@@ -2,17 +2,19 @@
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
* Guests hitting / are sent toward recordings, then login.
*/
public function test_the_application_returns_a_successful_response(): void
public function test_the_application_redirects_guests_to_login(): void
{
$this->get('/')
->assertRedirect('/recordings');
$this->get('/recordings')
->assertRedirect(route('login'));
}
}
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
@@ -14,6 +15,16 @@ class RecordingDuplicateUploadTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_duplicate_content_hash_is_skipped_on_upload(): void
{
Storage::fake('local');
@@ -62,6 +73,7 @@ class RecordingDuplicateUploadTest extends TestCase
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Existing',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
@@ -82,6 +94,7 @@ class RecordingDuplicateUploadTest extends TestCase
Bus::fake();
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Legacy',
'original_filename' => 'legacy.mp3',
'file_path' => 'recordings/legacy.mp3',
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RecordingOwnershipTest extends TestCase
{
use RefreshDatabase;
public function test_user_only_sees_their_own_recordings(): void
{
$owner = User::factory()->create();
$other = User::factory()->create();
Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Mine',
'original_filename' => 'mine.mp3',
'file_path' => 'recordings/mine.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
Recording::query()->create([
'user_id' => $other->id,
'title' => 'Theirs',
'original_filename' => 'theirs.mp3',
'file_path' => 'recordings/theirs.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($owner)
->get(route('recordings.index'))
->assertOk()
->assertSee('Mine')
->assertDontSee('Theirs');
}
public function test_user_cannot_view_another_users_recording(): void
{
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Private',
'original_filename' => 'private.mp3',
'file_path' => 'recordings/private.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($intruder)
->get(route('recordings.show', $recording))
->assertForbidden();
}
public function test_user_cannot_delete_another_users_recording(): void
{
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Keep me',
'original_filename' => 'keep.mp3',
'file_path' => 'recordings/keep.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($intruder)
->delete(route('recordings.destroy', $recording))
->assertForbidden();
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
}
}
+28
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Http\Requests\StoreRecordingRequest;
use App\Jobs\TranscribeRecording;
use App\Models\Recording;
use App\Models\User;
use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
@@ -17,9 +18,20 @@ class RecordingUploadTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_index_shows_recordings(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Pocket note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
@@ -150,6 +162,7 @@ class RecordingUploadTest extends TestCase
public function test_user_can_queue_local_transcription(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Dictation',
'original_filename' => 'dictation.mp3',
'file_path' => 'recordings/dictation.mp3',
@@ -173,6 +186,7 @@ class RecordingUploadTest extends TestCase
public function test_transcription_status_endpoint_returns_progress(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Live status',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
@@ -201,6 +215,7 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Hello from the recorder.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Sample',
'original_filename' => 'sample.mp3',
'file_path' => 'recordings/sample.mp3',
@@ -229,6 +244,7 @@ class RecordingUploadTest extends TestCase
});
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Bad',
'original_filename' => 'bad.mp3',
'file_path' => 'recordings/bad.mp3',
@@ -252,6 +268,7 @@ class RecordingUploadTest extends TestCase
public function test_orphaned_processing_is_recovered_on_status_poll(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Stuck',
'original_filename' => 'stuck.mp3',
'file_path' => 'recordings/stuck.mp3',
@@ -277,6 +294,7 @@ class RecordingUploadTest extends TestCase
public function test_recent_processing_is_not_marked_orphaned(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Still working',
'original_filename' => 'working.mp3',
'file_path' => 'recordings/working.mp3',
@@ -303,6 +321,7 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Recovered transcript.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Restart me',
'original_filename' => 'restart.mp3',
'file_path' => 'recordings/restart.mp3',
@@ -324,6 +343,7 @@ class RecordingUploadTest extends TestCase
public function test_user_can_stop_an_active_transcription(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Stop me',
'original_filename' => 'stop.mp3',
'file_path' => 'recordings/stop.mp3',
@@ -350,6 +370,7 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Restarted transcript.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Restart me while busy',
'original_filename' => 'switch.mp3',
'file_path' => 'recordings/switch.mp3',
@@ -377,6 +398,7 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Should be ignored.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Ignore late job',
'original_filename' => 'ignore.mp3',
'file_path' => 'recordings/ignore.mp3',
@@ -400,6 +422,7 @@ class RecordingUploadTest extends TestCase
public function test_recordings_can_be_searched_by_transcript(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Office chat',
'original_filename' => 'office.mp3',
'file_path' => 'recordings/office.mp3',
@@ -409,6 +432,7 @@ class RecordingUploadTest extends TestCase
]);
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Kitchen note',
'original_filename' => 'kitchen.mp3',
'file_path' => 'recordings/kitchen.mp3',
@@ -429,6 +453,7 @@ class RecordingUploadTest extends TestCase
Bus::fake();
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Keep me',
'original_filename' => 'keep.mp3',
'file_path' => 'recordings/keep.mp3',
@@ -454,6 +479,7 @@ class RecordingUploadTest extends TestCase
Bus::fake();
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Needs work',
'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3',
@@ -462,6 +488,7 @@ class RecordingUploadTest extends TestCase
]);
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Already done',
'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3',
@@ -481,6 +508,7 @@ class RecordingUploadTest extends TestCase
public function test_index_shows_human_status_labels(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Label check',
'original_filename' => 'label.mp3',
'file_path' => 'recordings/label.mp3',
+18 -2
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Events\RecordingTranscriptionUpdated;
use App\Jobs\TranscribeRecording;
use App\Models\Recording;
use App\Models\User;
use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
@@ -16,11 +17,22 @@ class TranscriptionBroadcastTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_report_progress_broadcasts_transcription_updated(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Broadcast progress',
'original_filename' => 'progress.mp3',
'file_path' => 'recordings/progress.mp3',
@@ -48,6 +60,7 @@ class TranscriptionBroadcastTest extends TestCase
Transcription::fake(['Hello from the recorder.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Sample',
'original_filename' => 'sample.mp3',
'file_path' => 'recordings/sample.mp3',
@@ -81,6 +94,7 @@ class TranscriptionBroadcastTest extends TestCase
});
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Bad',
'original_filename' => 'bad.mp3',
'file_path' => 'recordings/bad.mp3',
@@ -111,6 +125,7 @@ class TranscriptionBroadcastTest extends TestCase
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Cancel me',
'original_filename' => 'cancel.mp3',
'file_path' => 'recordings/cancel.mp3',
@@ -128,9 +143,10 @@ class TranscriptionBroadcastTest extends TestCase
});
}
public function test_broadcast_event_uses_public_recordings_channel(): void
public function test_broadcast_event_uses_private_recording_channel(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Channel',
'original_filename' => 'channel.mp3',
'file_path' => 'recordings/channel.mp3',
@@ -141,6 +157,6 @@ class TranscriptionBroadcastTest extends TestCase
$event = new RecordingTranscriptionUpdated($recording);
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
$this->assertSame('recordings', $event->broadcastOn()[0]->name);
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
}
}