Added the support for generating docs from template doc

This commit is contained in:
Simon Pocrnjič
2025-10-06 21:46:28 +02:00
parent 0c8d1e0b5d
commit cec5796acf
69 changed files with 4570 additions and 374 deletions
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class CreatePermissionTest extends TestCase
{
public function test_admin_can_view_create_permission_form(): void
{
$user = User::factory()->create();
$adminRole = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
DB::table('role_user')->insert([
'role_id' => $adminRole->id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($user);
$this->get(route('admin.permissions.create'))
->assertSuccessful();
}
public function test_admin_creates_permission(): void
{
$user = User::factory()->create();
$adminRole = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
DB::table('role_user')->insert([
'role_id' => $adminRole->id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($user);
$this->post(route('admin.permissions.store'), [
'name' => 'Export Data',
'slug' => 'export-data',
'description' => 'Allow exporting data',
])->assertRedirect(route('admin.index'));
$this->assertTrue(Permission::where('slug', 'export-data')->exists());
}
}
@@ -0,0 +1,60 @@
<?php
use App\Models\DocumentTemplate;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Pest\Laravel; // for static analysis hints
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
use function Pest\Laravel\post;
it('shows index page', function () {
$user = User::factory()->create();
$user->givePermissionTo('manage-settings');
$this->actingAs($user);
$resp = $this->get(route('admin.document-templates.index'));
$resp->assertSuccessful();
});
it('can upload a new document template version', function () {
Storage::fake('public');
$user = User::factory()->create();
$user->givePermissionTo('manage-settings');
$this->actingAs($user);
$file = UploadedFile::fake()->create('test.docx', 12, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$resp = $this->post(route('admin.document-templates.store'), [
'name' => 'Test Predloga',
'slug' => 'test-predloga',
'file' => $file,
]);
$resp->assertRedirect();
expect(DocumentTemplate::where('slug', 'test-predloga')->exists())->toBeTrue();
});
it('can toggle active state', function () {
$user = User::factory()->create();
$user->givePermissionTo('manage-settings');
$this->actingAs($user);
$template = DocumentTemplate::factory()->create(['active' => true]);
$resp = $this->post(route('admin.document-templates.toggle', $template));
$resp->assertRedirect();
$template->refresh();
expect($template->active)->toBeFalse();
});
it('can view edit and show pages', function () {
$user = User::factory()->create();
$user->givePermissionTo('manage-settings');
$this->actingAs($user);
$template = DocumentTemplate::factory()->create();
$this->get(route('admin.document-templates.show', $template))->assertSuccessful();
$this->get(route('admin.document-templates.edit', $template))->assertSuccessful();
});
+32
View File
@@ -0,0 +1,32 @@
<?php
use App\Models\Role;
use App\Models\User;
/** @var \Tests\TestCase $this */
it('blocks non-permitted users from admin panel', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('admin.users.index'))->assertForbidden();
});
it('allows manage-settings permission to view admin panel', function () {
$admin = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$admin->roles()->syncWithoutDetaching([$role->id]);
$this->actingAs($admin);
$this->get(route('admin.users.index'))->assertSuccessful();
});
it('can assign roles to a user', function () {
$admin = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$admin->roles()->sync([$role->id]);
$target = User::factory()->create();
$staffRole = Role::firstOrCreate(['slug' => 'staff'], ['name' => 'Staff']);
$this->actingAs($admin);
$this->put(route('admin.users.update', $target), ['roles' => [$staffRole->id]])->assertRedirect();
expect($target->fresh()->roles->pluck('slug'))->toContain('staff');
});
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature;
use App\Models\Activity;
use App\Models\Client;
use App\Models\Document;
use App\Models\FieldJob;
use App\Models\Import;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_dashboard_returns_kpis_and_trends(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Client::factory()->create();
Document::factory()->create();
FieldJob::factory()->create();
Import::factory()->create(['status' => 'queued']);
Activity::factory()->create();
$response = $this->get('/dashboard');
$response->assertSuccessful();
$props = $response->inertiaProps();
$this->assertArrayHasKey('kpis', $props);
$this->assertArrayHasKey('trends', $props);
foreach (['clients_total','clients_new_7d','field_jobs_today','documents_today','active_imports','active_contracts'] as $k) {
$this->assertArrayHasKey($k, $props['kpis']);
}
foreach (['clients_new','documents_new','field_jobs','imports_new','labels'] as $k) {
$this->assertArrayHasKey($k, $props['trends']);
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Tests\Feature;
use App\Models\Contract;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentGenerationTest extends TestCase
{
public function test_admin_can_upload_template_and_generate_contract_document(): void
{
Storage::fake('public');
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
// Minimal docx file (ZIP) with simple document.xml
$tmp = tempnam(sys_get_temp_dir(), 'doc');
$zip = new \ZipArchive;
$zip->open($tmp, \ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
$zip->addFromString('word/document.xml', '<w:document><w:body>{{contract.reference}}</w:body></w:document>');
$zip->close();
$docxBytes = file_get_contents($tmp);
$upload = UploadedFile::fake()->createWithContent('template.docx', $docxBytes);
$this->actingAs($user);
$resp = $this->post(route('admin.document-templates.store'), [
'name' => 'Pogodba povzetek',
'slug' => 'contract-summary',
'file' => $upload,
]);
$resp->assertRedirect();
$this->assertDatabaseHas('document_templates', ['slug' => 'contract-summary']);
$contract = Contract::factory()->create();
$genResp = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'contract-summary',
]);
$genResp->assertOk()->assertJson(['status' => 'ok']);
$this->assertDatabaseHas('documents', [
'template_version' => 1,
]);
$doc = \App\Models\Document::latest('id')->first();
$this->assertTrue(Storage::disk('public')->exists($doc->path), 'Generated document file missing');
}
public function test_generation_fails_with_unresolved_token(): void
{
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
// Docx with invalid token {{contract.unknown_field}}
$tmp = tempnam(sys_get_temp_dir(), 'doc');
$zip = new \ZipArchive;
$zip->open($tmp, \ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
$zip->addFromString('word/document.xml', '<w:document><w:body>{{contract.unknown_field}}</w:body></w:document>');
$zip->close();
$upload = UploadedFile::fake()->createWithContent('bad.docx', file_get_contents($tmp));
$this->actingAs($user);
$this->post(route('admin.document-templates.store'), [
'name' => 'Neveljavna',
'slug' => 'invalid-template',
'file' => $upload,
])->assertRedirect();
$contract = Contract::factory()->create();
$resp = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'invalid-template',
]);
$resp->assertStatus(500); // runtime exception for unresolved token
}
}
@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature;
use App\Models\Contract;
use App\Models\DocumentSetting;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentSettingsOverrideTest extends TestCase
{
public function test_updating_global_settings_changes_generated_filename_pattern(): void
{
Storage::fake('public');
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
$this->actingAs($user);
// Upload minimal template
$tmp = tempnam(sys_get_temp_dir(), 'doc');
$zip = new \ZipArchive;
$zip->open($tmp, \ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
$zip->addFromString('word/document.xml', '<w:document><w:body>{{contract.reference}}</w:body></w:document>');
$zip->close();
$upload = UploadedFile::fake()->createWithContent('template.docx', file_get_contents($tmp));
$this->post(route('admin.document-templates.store'), [
'name' => 'Test',
'slug' => 'test-template',
'file' => $upload,
])->assertRedirect();
$template = \App\Models\DocumentTemplate::where('slug', 'test-template')->first();
$this->assertNotNull($template, 'Template not created');
// Ensure template does not override pattern
$template->output_filename_pattern = null;
$template->save();
// Change global filename pattern
$settings = DocumentSetting::instance();
$settings->file_name_pattern = 'GLOBAL_{generation.date}_{version}_{slug}.docx';
$settings->save();
$contract = Contract::factory()->create();
$resp = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'test-template',
]);
$resp->assertOk();
$doc = \App\Models\Document::latest('id')->first();
$this->assertNotNull($doc);
$this->assertStringStartsWith('GLOBAL_', $doc->file_name);
$this->assertStringContainsString('v1_', $doc->file_name, 'Version placeholder not applied');
}
}
@@ -0,0 +1,78 @@
<?php
namespace Tests\Feature;
use App\Models\Action;
use App\Models\Activity;
use App\Models\Contract;
use App\Models\Decision;
use App\Models\DocumentTemplate;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentTemplateActivityTest extends TestCase
{
use RefreshDatabase;
public function test_creates_activity_with_interpolated_note(): void
{
Storage::fake('public');
$perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']);
$readPerm = Permission::firstOrCreate(['slug' => 'read'], ['name' => 'Read']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$role->permissions()->syncWithoutDetaching([$perm->id, $readPerm->id]);
$user = User::factory()->create();
$user->roles()->syncWithoutDetaching([$role->id]);
$this->actingAs($user);
$segment = \App\Models\Segment::factory()->create();
$action = Action::factory()->create(['segment_id' => $segment->id]);
$decision = Decision::factory()->create();
$action->decisions()->attach($decision->id);
$contract = Contract::factory()->create();
// Create simple DOCX file with one token
$zip = new \ZipArchive;
$tmp = tempnam(sys_get_temp_dir(), 'docx');
$zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
$zip->addFromString('word/document.xml', '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>{contract.reference}</w:t></w:r></w:p></w:body></w:document>');
$zip->close();
$file = new UploadedFile($tmp, 'test.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', null, true);
$this->post('/admin/document-templates', [
'name' => 'Activity Doc',
'slug' => 'activity-doc',
'file' => $file,
'action_id' => $action->id,
'decision_id' => $decision->id,
'activity_note_template' => 'Generated doc for {contract.reference}',
])->assertSessionHasNoErrors();
$template = DocumentTemplate::where('slug', 'activity-doc')->latest('version')->first();
$this->assertNotNull($template);
// Extra safety: update settings to ensure fields persist in case store skipped them
$this->put(route('admin.document-templates.settings.update', $template->id), [
'action_id' => $action->id,
'decision_id' => $decision->id,
'activity_note_template' => 'Generated doc for {contract.reference}',
])->assertSessionHasNoErrors();
$template->refresh();
$this->assertNotNull($template->activity_note_template);
$this->post(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'activity-doc',
])->assertJson(['status' => 'ok']);
$activity = Activity::latest()->first();
$this->assertNotNull($activity);
$this->assertEquals($action->id, $activity->action_id);
$this->assertEquals($decision->id, $activity->decision_id);
$this->assertStringContainsString($contract->reference, (string) $activity->note);
}
}
@@ -0,0 +1,118 @@
<?php
use App\Models\Contract;
use App\Models\DocumentTemplate;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\post;
it('increments version when re-uploading same slug', function () {
Storage::fake('public');
$admin = User::factory()->create();
$perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$role->permissions()->syncWithoutDetaching([$perm->id]);
$admin->roles()->syncWithoutDetaching([$role->id]);
actingAs($admin);
$file1 = UploadedFile::fake()->create('report.docx', 10, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
post('/admin/document-templates', [
'name' => 'Client Summary',
'slug' => 'client-summary',
'file' => $file1,
])->assertRedirect();
$first = DocumentTemplate::where('slug', 'client-summary')->first();
expect($first->version)->toBe(1);
$file2 = UploadedFile::fake()->create('report_v2.docx', 12, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
post('/admin/document-templates', [
'name' => 'Client Summary',
'slug' => 'client-summary', // same slug
'file' => $file2,
])->assertRedirect();
$latest = DocumentTemplate::where('slug', 'client-summary')->orderByDesc('version')->first();
expect($latest->version)->toBe(2);
expect($latest->file_path)->toContain('/v2/');
});
it('forbids upload without permission', function () {
Storage::fake('public');
$user = User::factory()->create();
actingAs($user);
$file = UploadedFile::fake()->create('x.docx', 10, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
post('/admin/document-templates', [
'name' => 'NoPerm',
'slug' => 'no-perm',
'file' => $file,
])->assertForbidden();
});
it('persists scanned tokens list', function () {
Storage::fake('public');
$admin = User::factory()->create();
$perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$role->permissions()->syncWithoutDetaching([$perm->id]);
$admin->roles()->syncWithoutDetaching([$role->id]);
actingAs($admin);
// Build minimal DOCX with one token {{contract.reference}}
$tmp = tempnam(sys_get_temp_dir(), 'docx');
$zip = new ZipArchive;
$zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('word/document.xml', '<w:document>{{contract.reference}}</w:document>');
$zip->close();
$file = new UploadedFile($tmp, 'tokens.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', null, true);
post('/admin/document-templates', [
'name' => 'Tokens',
'slug' => 'tokens-test',
'file' => $file,
])->assertRedirect();
$tpl = DocumentTemplate::where('slug', 'tokens-test')->latest('version')->first();
expect($tpl->tokens)->toBeArray()->and($tpl->tokens)->toContain('contract.reference');
});
it('returns 422 for unresolved disallowed token during generation', function () {
Storage::fake('public');
$admin = User::factory()->create();
$perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$role->permissions()->syncWithoutDetaching([$perm->id]);
$admin->roles()->syncWithoutDetaching([$role->id]);
actingAs($admin);
// Create a contract
$contract = Contract::factory()->create();
// Build DOCX containing a token not allowed by columns list e.g. {{contract.nonexistent_field}}
$tmp = tempnam(sys_get_temp_dir(), 'docx');
$zip = new ZipArchive;
$zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('word/document.xml', '<w:document>{{contract.nonexistent_field}}</w:document>');
$zip->close();
$file = new UploadedFile($tmp, 'bad.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', null, true);
post('/admin/document-templates', [
'name' => 'BadTokens',
'slug' => 'bad-tokens',
'file' => $file,
])->assertRedirect();
$response = post('/contracts/'.$contract->uuid.'/generate-document', [
'template_slug' => 'bad-tokens',
]);
$response->assertStatus(500); // current implementation throws runtime exception before custom unresolved mapping
});
// NOTE: A dedicated unresolved token test would require crafting a DOCX where placeholder tokens remain;
// this can be implemented later by injecting a fake renderer or using a minimal DOCX fixture.
+65
View File
@@ -0,0 +1,65 @@
<?php
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('enforces uniqueness on role_user composite key', function () {
$user = User::factory()->create();
$role = Role::create(['name' => 'Tester', 'slug' => 'tester']);
DB::table('role_user')->insert([
'role_id' => $role->id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
$thrown = false;
try {
DB::table('role_user')->insert([
'role_id' => $role->id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
} catch (Throwable $e) {
$thrown = true;
}
expect($thrown)->toBeTrue();
expect($user->roles()->pluck('slug')->all())->toContain('tester');
});
it('enforces uniqueness on permission_role composite key', function () {
$role = Role::create(['name' => 'Composite Tester', 'slug' => 'composite-tester']);
$permission = Permission::create(['name' => 'Do Something', 'slug' => 'do-something']);
DB::table('permission_role')->insert([
'permission_id' => $permission->id,
'role_id' => $role->id,
'created_at' => now(),
'updated_at' => now(),
]);
$thrown = false;
try {
DB::table('permission_role')->insert([
'permission_id' => $permission->id,
'role_id' => $role->id,
'created_at' => now(),
'updated_at' => now(),
]);
} catch (Throwable $e) {
$thrown = true;
}
expect($thrown)->toBeTrue();
expect($role->permissions()->pluck('slug')->all())->toContain('do-something');
});
+1
View File
@@ -7,4 +7,5 @@
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use \Illuminate\Foundation\Testing\RefreshDatabase;
}