feat: complete Epic 0 — foundation migration & infrastructure setup
Stories 0.2-0.5: rename folders→declarations (backend+frontend), configure Redis for cache/queue/sessions, add foundation database migrations (permissions, archived_at), replace DeclarationStatus enum with architecture lifecycle values, create DeclarationObserver for status transition validation and auto-archive, fix controller status transitions to respect observer rules. 93 tests pass (240 assertions). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
108
tests/Feature/Declaration/DeclarationStatusFlowTest.php
Normal file
108
tests/Feature/Declaration/DeclarationStatusFlowTest.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\DeclarationStatus;
|
||||
use App\Models\Declaration;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
test('valid transition: created to en_cours', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
|
||||
expect($declaration->fresh()->status->value)->toBe('en_cours');
|
||||
});
|
||||
|
||||
test('valid transition: en_cours to en_attente_client', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
|
||||
expect($declaration->fresh()->status->value)->toBe('en_attente_client');
|
||||
});
|
||||
|
||||
test('valid transition: en_attente_client to en_cours', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
|
||||
expect($declaration->fresh()->status->value)->toBe('en_cours');
|
||||
});
|
||||
|
||||
test('valid transition: en_cours to termine', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::Termine]);
|
||||
|
||||
expect($declaration->fresh()->status->value)->toBe('termine');
|
||||
});
|
||||
|
||||
test('valid transition: termine to ferme', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
$declaration->update(['status' => DeclarationStatus::Termine]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::Ferme]);
|
||||
|
||||
expect($declaration->fresh()->status->value)->toBe('ferme');
|
||||
});
|
||||
|
||||
test('invalid transition: created to ferme throws validation exception', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::Ferme]);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('invalid transition: created to termine throws validation exception', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::Termine]);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('auto-archive: ferme status sets archived_at', function () {
|
||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
$declaration->update(['status' => DeclarationStatus::Termine]);
|
||||
|
||||
expect($declaration->fresh()->archived_at)->toBeNull();
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::Ferme]);
|
||||
|
||||
$fresh = $declaration->fresh();
|
||||
expect($fresh->archived_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('scope active excludes archived declarations', function () {
|
||||
$active = Declaration::factory()->create([
|
||||
'status' => DeclarationStatus::Created,
|
||||
'archived_at' => null,
|
||||
]);
|
||||
$archived = Declaration::factory()->create([
|
||||
'status' => DeclarationStatus::Created,
|
||||
'archived_at' => now(),
|
||||
]);
|
||||
|
||||
$activeIds = Declaration::active()->pluck('id')->all();
|
||||
|
||||
expect($activeIds)->toContain($active->id);
|
||||
expect($activeIds)->not->toContain($archived->id);
|
||||
});
|
||||
|
||||
test('scope archived includes only archived declarations', function () {
|
||||
$active = Declaration::factory()->create([
|
||||
'status' => DeclarationStatus::Created,
|
||||
'archived_at' => null,
|
||||
]);
|
||||
$archived = Declaration::factory()->create([
|
||||
'status' => DeclarationStatus::Created,
|
||||
'archived_at' => now(),
|
||||
]);
|
||||
|
||||
$archivedIds = Declaration::archived()->pluck('id')->all();
|
||||
|
||||
expect($archivedIds)->toContain($archived->id);
|
||||
expect($archivedIds)->not->toContain($active->id);
|
||||
});
|
||||
134
tests/Feature/Declaration/DeclarationTypeTest.php
Normal file
134
tests/Feature/Declaration/DeclarationTypeTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
test('can create vat_monthly declaration 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('declarations.store'), [
|
||||
'client_id' => $client->id,
|
||||
'title' => 'TVA Mensuelle Mars 2026',
|
||||
'type' => 'vat_monthly',
|
||||
'period_year' => 2026,
|
||||
'period_month' => 3,
|
||||
'status' => 'created',
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$declaration = $client->declarations()->where('type', 'vat_monthly')->first();
|
||||
expect($declaration)->not->toBeNull();
|
||||
expect($declaration->period_month)->toBe(3);
|
||||
expect($declaration->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('declarations.store'), [
|
||||
'client_id' => $client->id,
|
||||
'title' => 'TVA Mensuelle Sans Mois',
|
||||
'type' => 'vat_monthly',
|
||||
'period_year' => 2026,
|
||||
'status' => 'created',
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('period_month');
|
||||
});
|
||||
|
||||
test('can create vat_quarterly declaration 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('declarations.store'), [
|
||||
'client_id' => $client->id,
|
||||
'title' => 'TVA Trimestrielle T1 2026',
|
||||
'type' => 'vat_quarterly',
|
||||
'period_year' => 2026,
|
||||
'period_quarter' => 1,
|
||||
'status' => 'created',
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$declaration = $client->declarations()->where('type', 'vat_quarterly')->first();
|
||||
expect($declaration)->not->toBeNull();
|
||||
expect($declaration->period_quarter)->toBe(1);
|
||||
expect($declaration->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('declarations.store'), [
|
||||
'client_id' => $client->id,
|
||||
'title' => 'TVA Trimestrielle Sans Trimestre',
|
||||
'type' => 'vat_quarterly',
|
||||
'period_year' => 2026,
|
||||
'status' => 'created',
|
||||
'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('declarations.store'), [
|
||||
'client_id' => $client->id,
|
||||
'title' => 'Old VAT',
|
||||
'type' => 'vat',
|
||||
'period_year' => 2026,
|
||||
'status' => 'created',
|
||||
'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('declarations.store'), [
|
||||
'client_id' => $client->id,
|
||||
'title' => 'TVA Mensuelle With Quarter',
|
||||
'type' => 'vat_monthly',
|
||||
'period_year' => 2026,
|
||||
'period_month' => 6,
|
||||
'period_quarter' => 2,
|
||||
'status' => 'created',
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$declaration = $client->declarations()->where('title', 'TVA Mensuelle With Quarter')->first();
|
||||
expect($declaration->period_quarter)->toBeNull();
|
||||
expect($declaration->period_month)->toBe(6);
|
||||
});
|
||||
103
tests/Feature/Declaration/MediaDownloadTest.php
Normal file
103
tests/Feature/Declaration/MediaDownloadTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Declaration;
|
||||
use App\Models\MediaDownload;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
function setupDeclarationWithMedia(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user, ['role' => 'owner']);
|
||||
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
|
||||
$declaration = Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
]);
|
||||
|
||||
Storage::fake('public');
|
||||
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
|
||||
$media = $declaration->addMedia($file)->toMediaCollection('documents');
|
||||
|
||||
return [$user, $workspace, $declaration, $media];
|
||||
}
|
||||
|
||||
test('downloading creates a media download record', function () {
|
||||
[$user, $workspace, $declaration, $media] = setupDeclarationWithMedia();
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)->get(route('declarations.media.download', [
|
||||
'declaration' => $declaration,
|
||||
'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, $declaration, $media] = setupDeclarationWithMedia();
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)->get(route('declarations.media.download', [
|
||||
'declaration' => $declaration,
|
||||
'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('declarations.media.download', [
|
||||
'declaration' => $declaration,
|
||||
'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, $declaration, $media] = setupDeclarationWithMedia();
|
||||
$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('declarations.show', $declaration));
|
||||
$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('declarations.show', $declaration));
|
||||
$response2->assertOk();
|
||||
$documents2 = $response2->original->getData()['page']['props']['documents'];
|
||||
$doc2 = collect($documents2)->firstWhere('id', $media->id);
|
||||
expect($doc2['is_downloaded'])->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user