feat: L'Ami Fiduciaire V1.0.0 — full codebase with Story 0.1 complete

Initial commit of the L'Ami Fiduciaire SaaS platform built on Laravel 12,
Vue 3, Inertia.js 2, and Tailwind CSS 4.

Story 0.1 (rename folders to declarations in database) is implemented and
code-reviewed: migration, rollback, and 6 Pest tests all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:33:10 +00:00
commit 35545c2a8f
1517 changed files with 246774 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Fortify\Features;
test('login screen can be rendered', function () {
$response = $this->get(route('login'));
$response->assertOk();
});
test('users can authenticate using the login screen', function () {
$user = User::factory()->create();
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
});
test('users with two factor enabled are redirected to two factor challenge', function () {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->create();
$user->forceFill([
'two_factor_secret' => encrypt('test-secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
'two_factor_confirmed_at' => now(),
])->save();
$response = $this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect(route('two-factor.login'));
$response->assertSessionHas('login.id', $user->id);
$this->assertGuest();
});
test('users can not authenticate with invalid password', function () {
$user = User::factory()->create();
$this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
});
test('users can logout', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('logout'));
$this->assertGuest();
$response->assertRedirect(route('home'));
});
test('users are rate limited', function () {
$user = User::factory()->create();
RateLimiter::increment(md5('login'.implode('|', [$user->email, '127.0.0.1'])), amount: 5);
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertTooManyRequests();
});

View File

@@ -0,0 +1,95 @@
<?php
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
test('email verification screen can be rendered', function () {
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get(route('verification.notice'));
$response->assertOk();
});
test('email can be verified', function () {
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
});
test('email is not verified with invalid hash', function () {
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')],
);
$this->actingAs($user)->get($verificationUrl);
Event::assertNotDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});
test('email is not verified with invalid user id', function () {
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => 123, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl);
Event::assertNotDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});
test('verified user is redirected to dashboard from verification prompt', function () {
$user = User::factory()->create();
Event::fake();
$response = $this->actingAs($user)->get(route('verification.notice'));
Event::assertNotDispatched(Verified::class);
$response->assertRedirect(route('dashboard', absolute: false));
});
test('already verified user visiting verification link is redirected without firing event again', function () {
$user = User::factory()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('dashboard', absolute: false).'?verified=1');
Event::assertNotDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
});

View File

@@ -0,0 +1,22 @@
<?php
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
test('confirm password screen can be rendered', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('password.confirm'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('auth/ConfirmPassword'),
);
});
test('password confirmation requires authentication', function () {
$response = $this->get(route('password.confirm'));
$response->assertRedirect(route('login'));
});

View File

@@ -0,0 +1,73 @@
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\Notification;
test('reset password link screen can be rendered', function () {
$response = $this->get(route('password.request'));
$response->assertOk();
});
test('reset password link can be requested', function () {
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
});
test('reset password screen can be rendered', function () {
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get(route('password.reset', $notification->token));
$response->assertOk();
return true;
});
});
test('password can be reset with valid token', function () {
Notification::fake();
$user = User::factory()->create();
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post(route('password.update'), [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
});
test('password cannot be reset with invalid token', function () {
$user = User::factory()->create();
$response = $this->post(route('password.update'), [
'token' => 'invalid-token',
'email' => $user->email,
'password' => 'newpassword123',
'password_confirmation' => 'newpassword123',
]);
$response->assertSessionHasErrors('email');
});

View File

@@ -0,0 +1,19 @@
<?php
test('registration screen can be rendered', function () {
$response = $this->get(route('register'));
$response->assertOk();
});
test('new users can register', function () {
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
});

View File

@@ -0,0 +1,45 @@
<?php
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
test('two factor challenge redirects to login when not authenticated', function () {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
$response = $this->get(route('two-factor.login'));
$response->assertRedirect(route('login'));
});
test('two factor challenge can be rendered', function () {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->create();
$user->forceFill([
'two_factor_secret' => encrypt('test-secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
'two_factor_confirmed_at' => now(),
])->save();
$this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
$this->get(route('two-factor.login'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('auth/TwoFactorChallenge'),
);
});

View File

@@ -0,0 +1,29 @@
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Support\Facades\Notification;
test('sends verification notification', function () {
Notification::fake();
$user = User::factory()->unverified()->create();
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('home'));
Notification::assertSentTo($user, VerifyEmail::class);
});
test('does not send verification notification if email is verified', function () {
Notification::fake();
$user = User::factory()->create();
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('dashboard', absolute: false));
Notification::assertNothingSent();
});

View File

@@ -0,0 +1,152 @@
<?php
use App\Models\Client;
use App\Models\ClientContact;
use App\Models\User;
use App\Models\Workspace;
function createWorkspaceWithUser(): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user, ['role' => 'owner']);
return [$user, $workspace];
}
test('can store a client with a single contact', function () {
[$user, $workspace] = createWorkspaceWithUser();
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('clients.store'), [
'company_name' => 'Test SARL',
'legal_form' => 'sarl',
'contacts' => [
[
'full_name' => 'Ahmed Bennani',
'job_title' => 'Gérant',
'email' => 'ahmed@test.com',
'phone' => '0612345678',
'is_principal' => true,
],
],
]);
$response->assertRedirect();
$client = Client::query()->where('company_name', 'Test SARL')->first();
expect($client)->not->toBeNull();
expect($client->contacts)->toHaveCount(1);
expect($client->contacts->first()->full_name)->toBe('Ahmed Bennani');
expect($client->contacts->first()->is_principal)->toBeTrue();
});
test('can store a client with multiple contacts', function () {
[$user, $workspace] = createWorkspaceWithUser();
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('clients.store'), [
'company_name' => 'Multi Contact SARL',
'legal_form' => 'sarl',
'contacts' => [
[
'full_name' => 'Contact A',
'email' => 'a@test.com',
'is_principal' => false,
],
[
'full_name' => 'Contact B',
'email' => 'b@test.com',
'is_principal' => true,
],
[
'full_name' => 'Contact C',
'email' => 'c@test.com',
'is_principal' => false,
],
],
]);
$response->assertRedirect();
$client = Client::query()->where('company_name', 'Multi Contact SARL')->first();
expect($client->contacts)->toHaveCount(3);
$principal = $client->contacts->firstWhere('is_principal', true);
expect($principal->full_name)->toBe('Contact B');
});
test('store validation fails with no contacts', function () {
[$user, $workspace] = createWorkspaceWithUser();
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('clients.store'), [
'company_name' => 'No Contact SARL',
'legal_form' => 'sarl',
'contacts' => [],
]);
$response->assertSessionHasErrors('contacts');
});
test('store validation fails with no principal contact', function () {
[$user, $workspace] = createWorkspaceWithUser();
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('clients.store'), [
'company_name' => 'No Principal SARL',
'legal_form' => 'sarl',
'contacts' => [
['full_name' => 'Test', 'email' => 't@t.com', 'is_principal' => false],
],
]);
$response->assertSessionHasErrors('contacts');
});
test('store validation fails with multiple principal contacts', function () {
[$user, $workspace] = createWorkspaceWithUser();
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('clients.store'), [
'company_name' => 'Multi Principal SARL',
'legal_form' => 'sarl',
'contacts' => [
['full_name' => 'A', 'email' => 'a@t.com', 'is_principal' => true],
['full_name' => 'B', 'email' => 'b@t.com', 'is_principal' => true],
],
]);
$response->assertSessionHasErrors('contacts');
});
test('update can add and remove contacts', function () {
[$user, $workspace] = createWorkspaceWithUser();
session(['current_workspace_id' => $workspace->id]);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$existingContact = $client->contacts->first();
$response = $this->actingAs($user)->put(route('clients.update', $client), [
'company_name' => $client->company_name,
'legal_form' => $client->legal_form->value,
'contacts' => [
[
'full_name' => 'New Contact',
'email' => 'new@test.com',
'is_principal' => true,
],
],
]);
$response->assertRedirect();
$client->refresh();
expect($client->contacts)->toHaveCount(1);
expect($client->contacts->first()->full_name)->toBe('New Contact');
expect(ClientContact::find($existingContact->id))->toBeNull();
});
test('primary contact email accessor returns principal contact email', function () {
$client = Client::factory()->create();
$principal = $client->contacts->firstWhere('is_principal', true);
expect($client->primary_contact_email)->toBe($principal->email);
});

View File

@@ -0,0 +1,34 @@
<?php
use App\Models\Client;
use App\Models\ClientContact;
test('primary_contact_email returns principal contact email', function () {
$client = Client::factory()->create();
$principal = $client->contacts->firstWhere('is_principal', true);
expect($client->primary_contact_email)->toBe($principal->email);
});
test('primary_contact_email falls back to old contact_email column', function () {
$client = Client::factory()->create([
'contact_email' => 'fallback@test.com',
]);
// Remove all contacts to force fallback
$client->contacts()->delete();
$client->unsetRelation('primaryContact');
expect($client->primary_contact_email)->toBe('fallback@test.com');
});
test('primary_contact_email returns null when no contact exists', function () {
$client = Client::factory()->create([
'contact_email' => null,
]);
$client->contacts()->delete();
$client->unsetRelation('primaryContact');
expect($client->primary_contact_email)->toBeNull();
});

View File

@@ -0,0 +1,16 @@
<?php
use App\Models\User;
test('guests are redirected to the login page', function () {
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('login'));
});
test('authenticated users can visit the dashboard', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertOk();
});

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
test('declarations table exists after migration', function () {
expect(Schema::hasTable('declarations'))->toBeTrue();
expect(Schema::hasTable('folders'))->toBeFalse();
});
test('declaration_invitations table exists after migration', function () {
expect(Schema::hasTable('declaration_invitations'))->toBeTrue();
expect(Schema::hasTable('folder_invitations'))->toBeFalse();
});
test('messages table has declaration_id column instead of folder_id', function () {
expect(Schema::hasColumn('messages', 'declaration_id'))->toBeTrue();
expect(Schema::hasColumn('messages', 'folder_id'))->toBeFalse();
});
test('declaration_invitations table has declaration_id column instead of folder_id', function () {
expect(Schema::hasColumn('declaration_invitations', 'declaration_id'))->toBeTrue();
expect(Schema::hasColumn('declaration_invitations', 'folder_id'))->toBeFalse();
});
test('composite index exists on messages declaration_id and created_at', function () {
$indexes = collect(DB::select("PRAGMA index_list('messages')"));
$indexColumns = $indexes->flatMap(function ($index) {
return collect(DB::select("PRAGMA index_info('{$index->name}')"))
->pluck('name')
->all();
});
expect($indexColumns->contains('declaration_id'))->toBeTrue();
expect($indexColumns->contains('created_at'))->toBeTrue();
});
test('migration is reversible and rollback restores folder tables', function () {
// Rollback the rename migration (last one applied)
$this->artisan('migrate:rollback', ['--step' => 1]);
expect(Schema::hasTable('folders'))->toBeTrue();
expect(Schema::hasTable('declarations'))->toBeFalse();
expect(Schema::hasTable('folder_invitations'))->toBeTrue();
expect(Schema::hasTable('declaration_invitations'))->toBeFalse();
expect(Schema::hasColumn('messages', 'folder_id'))->toBeTrue();
expect(Schema::hasColumn('messages', 'declaration_id'))->toBeFalse();
// Re-apply to leave DB in correct state for other tests
$this->artisan('migrate');
});

View File

@@ -0,0 +1,7 @@
<?php
test('returns a successful response', function () {
$response = $this->get(route('home'));
$response->assertOk();
});

View File

@@ -0,0 +1,134 @@
<?php
use App\Models\Client;
use App\Models\User;
use App\Models\Workspace;
test('can create vat_monthly folder requiring month', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user, ['role' => 'owner']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('folders.store'), [
'client_id' => $client->id,
'title' => 'TVA Mensuelle Mars 2026',
'type' => 'vat_monthly',
'period_year' => 2026,
'period_month' => 3,
'status' => 'draft',
'priority' => 'medium',
]);
$response->assertRedirect();
$folder = $client->folders()->where('type', 'vat_monthly')->first();
expect($folder)->not->toBeNull();
expect($folder->period_month)->toBe(3);
expect($folder->period_quarter)->toBeNull();
});
test('vat_monthly validation fails without month', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user, ['role' => 'owner']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('folders.store'), [
'client_id' => $client->id,
'title' => 'TVA Mensuelle Sans Mois',
'type' => 'vat_monthly',
'period_year' => 2026,
'status' => 'draft',
'priority' => 'medium',
]);
$response->assertSessionHasErrors('period_month');
});
test('can create vat_quarterly folder requiring quarter', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user, ['role' => 'owner']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('folders.store'), [
'client_id' => $client->id,
'title' => 'TVA Trimestrielle T1 2026',
'type' => 'vat_quarterly',
'period_year' => 2026,
'period_quarter' => 1,
'status' => 'draft',
'priority' => 'medium',
]);
$response->assertRedirect();
$folder = $client->folders()->where('type', 'vat_quarterly')->first();
expect($folder)->not->toBeNull();
expect($folder->period_quarter)->toBe(1);
expect($folder->period_month)->toBeNull();
});
test('vat_quarterly validation fails without quarter', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user, ['role' => 'owner']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('folders.store'), [
'client_id' => $client->id,
'title' => 'TVA Trimestrielle Sans Trimestre',
'type' => 'vat_quarterly',
'period_year' => 2026,
'status' => 'draft',
'priority' => 'medium',
]);
$response->assertSessionHasErrors('period_quarter');
});
test('server rejects old vat type', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user, ['role' => 'owner']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('folders.store'), [
'client_id' => $client->id,
'title' => 'Old VAT',
'type' => 'vat',
'period_year' => 2026,
'status' => 'draft',
'priority' => 'medium',
]);
$response->assertSessionHasErrors('type');
});
test('vat_monthly nulls quarter field server-side', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user, ['role' => 'owner']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($user)->post(route('folders.store'), [
'client_id' => $client->id,
'title' => 'TVA Mensuelle With Quarter',
'type' => 'vat_monthly',
'period_year' => 2026,
'period_month' => 6,
'period_quarter' => 2,
'status' => 'draft',
'priority' => 'medium',
]);
$response->assertRedirect();
$folder = $client->folders()->where('title', 'TVA Mensuelle With Quarter')->first();
expect($folder->period_quarter)->toBeNull();
expect($folder->period_month)->toBe(6);
});

View File

@@ -0,0 +1,103 @@
<?php
use App\Models\Client;
use App\Models\Folder;
use App\Models\MediaDownload;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
function setupFolderWithMedia(): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user, ['role' => 'owner']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$folder = Folder::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
Storage::fake('public');
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$media = $folder->addMedia($file)->toMediaCollection('documents');
return [$user, $workspace, $folder, $media];
}
test('downloading creates a media download record', function () {
[$user, $workspace, $folder, $media] = setupFolderWithMedia();
session(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)->get(route('folders.media.download', [
'folder' => $folder,
'mediaId' => $media->id,
]));
$download = MediaDownload::query()
->where('media_id', $media->id)
->where('user_id', $user->id)
->first();
expect($download)->not->toBeNull();
expect($download->downloaded_at)->not->toBeNull();
});
test('re-downloading updates timestamp without creating duplicates', function () {
[$user, $workspace, $folder, $media] = setupFolderWithMedia();
session(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)->get(route('folders.media.download', [
'folder' => $folder,
'mediaId' => $media->id,
]));
$firstDownload = MediaDownload::query()
->where('media_id', $media->id)
->where('user_id', $user->id)
->first();
$firstTimestamp = $firstDownload->downloaded_at;
$this->travel(5)->minutes();
$this->actingAs($user)->get(route('folders.media.download', [
'folder' => $folder,
'mediaId' => $media->id,
]));
$count = MediaDownload::query()
->where('media_id', $media->id)
->where('user_id', $user->id)
->count();
expect($count)->toBe(1);
$firstDownload->refresh();
expect($firstDownload->downloaded_at->gt($firstTimestamp))->toBeTrue();
});
test('download status is per-user in show endpoint', function () {
[$user, $workspace, $folder, $media] = setupFolderWithMedia();
$otherUser = User::factory()->create();
$workspace->users()->attach($otherUser, ['role' => 'member']);
session(['current_workspace_id' => $workspace->id]);
MediaDownload::query()->create([
'media_id' => $media->id,
'user_id' => $user->id,
'downloaded_at' => now(),
]);
$response = $this->actingAs($user)->get(route('folders.show', $folder));
$response->assertOk();
$documents = $response->original->getData()['page']['props']['documents'];
$doc = collect($documents)->firstWhere('id', $media->id);
expect($doc['is_downloaded'])->toBeTrue();
$response2 = $this->actingAs($otherUser)->get(route('folders.show', $folder));
$response2->assertOk();
$documents2 = $response2->original->getData()['page']['props']['documents'];
$doc2 = collect($documents2)->firstWhere('id', $media->id);
expect($doc2['is_downloaded'])->toBeFalse();
});

View File

@@ -0,0 +1,97 @@
<?php
use App\Models\Client;
use App\Models\Folder;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\FolderMentionNotification;
use Illuminate\Support\Facades\Notification;
function setupMentionScenario(string $role = 'owner'): array
{
$sender = User::factory()->create();
$target = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($sender, ['role' => $role]);
$workspace->users()->attach($target, ['role' => 'member']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$folder = Folder::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
return [$sender, $target, $workspace, $folder];
}
test('owner can mention a workspace user', function () {
Notification::fake();
[$sender, $target, $workspace, $folder] = setupMentionScenario('owner');
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($sender)->post(route('folders.mentions.store', $folder), [
'user_id' => $target->id,
'message' => 'Please check this folder.',
]);
$response->assertRedirect();
Notification::assertSentTo($target, FolderMentionNotification::class);
});
test('manager can mention a workspace user', function () {
Notification::fake();
[$sender, $target, $workspace, $folder] = setupMentionScenario('manager');
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($sender)->post(route('folders.mentions.store', $folder), [
'user_id' => $target->id,
'message' => 'Please check this folder.',
]);
$response->assertRedirect();
Notification::assertSentTo($target, FolderMentionNotification::class);
});
test('member cannot mention a workspace user', function () {
Notification::fake();
[$sender, $target, $workspace, $folder] = setupMentionScenario('member');
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($sender)->post(route('folders.mentions.store', $folder), [
'user_id' => $target->id,
'message' => 'Please check this folder.',
]);
$response->assertForbidden();
Notification::assertNothingSent();
});
test('cannot mention user from another workspace', function () {
Notification::fake();
[$sender, , $workspace, $folder] = setupMentionScenario('owner');
$outsider = User::factory()->create();
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($sender)->post(route('folders.mentions.store', $folder), [
'user_id' => $outsider->id,
'message' => 'Hello',
]);
$response->assertSessionHasErrors('user_id');
Notification::assertNothingSent();
});
test('notification is persisted in database', function () {
[$sender, $target, $workspace, $folder] = setupMentionScenario('owner');
session(['current_workspace_id' => $workspace->id]);
$this->actingAs($sender)->post(route('folders.mentions.store', $folder), [
'user_id' => $target->id,
'message' => 'Check this.',
]);
expect($target->notifications()->count())->toBe(1);
$notif = $target->notifications()->first();
expect($notif->data['folder_id'])->toBe($folder->id);
expect($notif->data['message'])->toBe('Check this.');
});

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\User;
use App\Notifications\FolderMentionNotification;
test('user can mark own notification as read', function () {
$user = User::factory()->create();
$user->notify(new FolderMentionNotification(
folderId: 1,
folderTitle: 'Test Folder',
mentionedById: 999,
mentionedByName: 'Admin',
message: 'Please review.',
url: '/folders/1',
));
$notification = $user->notifications()->first();
expect($notification->read_at)->toBeNull();
$response = $this->actingAs($user)->post(route('notifications.read', $notification->id));
$response->assertRedirect();
$notification->refresh();
expect($notification->read_at)->not->toBeNull();
});
test('cannot mark another user notification as read', function () {
$user = User::factory()->create();
$other = User::factory()->create();
$other->notify(new FolderMentionNotification(
folderId: 1,
folderTitle: 'Test',
mentionedById: 999,
mentionedByName: 'Admin',
message: 'Hey.',
url: '/folders/1',
));
$notification = $other->notifications()->first();
$response = $this->actingAs($user)->post(route('notifications.read', $notification->id));
$response->assertNotFound();
});
test('user can mark all notifications as read', function () {
$user = User::factory()->create();
for ($i = 0; $i < 3; $i++) {
$user->notify(new FolderMentionNotification(
folderId: $i,
folderTitle: "Folder $i",
mentionedById: 999,
mentionedByName: 'Admin',
message: "Message $i",
url: "/folders/$i",
));
}
expect($user->unreadNotifications()->count())->toBe(3);
$response = $this->actingAs($user)->post(route('notifications.readAll'));
$response->assertRedirect();
expect($user->unreadNotifications()->count())->toBe(0);
});

View File

@@ -0,0 +1,50 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
test('password update page is displayed', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get(route('user-password.edit'));
$response->assertOk();
});
test('password can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('user-password.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('user-password.edit'));
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
test('correct password must be provided to update password', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('user-password.edit'))
->put(route('user-password.update'), [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrors('current_password')
->assertRedirect(route('user-password.edit'));
});

View File

@@ -0,0 +1,85 @@
<?php
use App\Models\User;
test('profile page is displayed', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get(route('profile.edit'));
$response->assertOk();
});
test('profile information can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
$user->refresh();
expect($user->name)->toBe('Test User');
expect($user->email)->toBe('test@example.com');
expect($user->email_verified_at)->toBeNull();
});
test('email verification status is unchanged when the email address is unchanged', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
expect($user->refresh()->email_verified_at)->not->toBeNull();
});
test('user can delete their account', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete(route('profile.destroy'), [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('home'));
$this->assertGuest();
expect($user->fresh())->toBeNull();
});
test('correct password must be provided to delete account', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('profile.edit'))
->delete(route('profile.destroy'), [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrors('password')
->assertRedirect(route('profile.edit'));
expect($user->fresh())->not->toBeNull();
});

View File

@@ -0,0 +1,79 @@
<?php
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
test('two factor settings page can be rendered', function () {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('two-factor.show'))
->assertInertia(fn (Assert $page) => $page
->component('settings/TwoFactor')
->where('twoFactorEnabled', false),
);
});
test('two factor settings page requires password confirmation when enabled', function () {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
$user = User::factory()->create();
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$response = $this->actingAs($user)
->get(route('two-factor.show'));
$response->assertRedirect(route('password.confirm'));
});
test('two factor settings page does not requires password confirmation when disabled', function () {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
$user = User::factory()->create();
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => false,
]);
$this->actingAs($user)
->get(route('two-factor.show'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/TwoFactor'),
);
});
test('two factor settings page returns forbidden response when two factor is disabled', function () {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
}
config(['fortify.features' => []]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('two-factor.show'))
->assertForbidden();
});

View File

@@ -0,0 +1,33 @@
<?php
use App\Enums\UserGroup;
use App\Models\User;
test('user factory creates user with default User group', function () {
$user = User::factory()->create();
expect($user->group)->toBeInstanceOf(UserGroup::class)
->and($user->group->is(UserGroup::User))->toBeTrue()
->and($user->group->value)->toBe('user');
});
test('user factory superadmin state assigns Superadmin group', function () {
$user = User::factory()->superadmin()->create();
expect($user->group->is(UserGroup::Superadmin))->toBeTrue()
->and($user->group->value)->toBe('superadmin');
});
test('user factory admin state assigns Admin group', function () {
$user = User::factory()->admin()->create();
expect($user->group->is(UserGroup::Admin))->toBeTrue()
->and($user->group->value)->toBe('admin');
});
test('group is persisted and retrieved correctly from database', function () {
$user = User::factory()->admin()->create();
$user->refresh();
expect($user->group->is(UserGroup::Admin))->toBeTrue();
});

47
tests/Pest.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function something()
{
// ..
}

10
tests/TestCase.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

View File

@@ -0,0 +1,5 @@
<?php
test('that true is true', function () {
expect(true)->toBeTrue();
});