create(); $project = Project::factory()->create([ 'name' => 'Core Platform', 'key' => 'CORE', ]); $template = ProjectTemplate::factory()->create([ 'name' => 'Foundation Template', 'slug' => 'foundation-template', 'source_project_id' => $project->id, ]); ProjectTemplate::factory()->create([ 'name' => 'Hidden Base Template', 'slug' => 'hidden-base-template', 'project_id' => $project->id, 'source_project_id' => $project->id, 'is_published' => false, ]); $this->syncGlobalPermissions($user, ['project:create']); $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->get('/project-templates') ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('project-templates/Index') ->has('templates', 1) ->where('templates.0.id', $template->id) ->where('templates.0.slug', 'foundation-template') ->where('templates.0.is_published', true)); $this->actingAs($user) ->get(route('projects.templates.create', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('project-templates/Create') ->where('project.id', $project->id) ->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas()))); $this->actingAs($user) ->get(route('project-templates.edit', $template)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('project-templates/Edit') ->where('template.id', $template->id) ->where('template.slug', 'foundation-template') ->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas()))); } public function test_users_can_save_and_delete_templates_from_projects(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'name' => 'Source Project', 'key' => 'SRC1', 'show_author' => false, 'show_story_points' => false, ]); $this->syncGlobalPermissions($user, ['project:create']); $this->syncProjectPermissions($project, $user, ['view']); $project->ticketTypes()->delete(); $project->ticketStatuses()->delete(); $project->boardColumns()->delete(); $boardColumn = BoardColumn::factory()->create([ 'project_id' => $project->id, 'name' => 'Doing', 'position' => 1, ]); $ticketStatus = TicketStatus::factory()->create([ 'project_id' => $project->id, 'board_column_id' => $boardColumn->id, 'name' => 'Doing', 'position' => 1, ]); $ticketStatus->allowedTargetStatuses()->sync([$ticketStatus->id]); TicketType::factory()->create([ 'project_id' => $project->id, 'name' => 'Bug', 'position' => 1, ]); $this->actingAs($user) ->post(route('projects.templates.store', $project), [ 'name' => 'Core Reusable', 'slug' => 'core-reusable', 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], ]) ->assertRedirect('/project-templates'); $template = ProjectTemplate::query()->where('slug', 'core-reusable')->firstOrFail(); $this->assertTrue($template->is_published); $this->assertNull($template->project_id); $this->assertSame([ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ 'show_author' => false, 'show_story_points' => false, ], DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ [ 'name' => 'Bug', 'position' => 1, 'is_parent' => false, ], ], ], $template->configuration_snapshot); $this->actingAs($user) ->delete(route('project-templates.destroy', $template)) ->assertRedirect('/project-templates'); $this->assertDatabaseMissing('project_templates', [ 'id' => $template->id, ]); } public function test_hidden_base_templates_cannot_be_deleted_from_the_management_flow(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $template = ProjectTemplate::factory()->create([ 'project_id' => $project->id, 'source_project_id' => $project->id, 'is_published' => false, ]); $this->syncGlobalPermissions($user, ['project:create']); $this->actingAs($user) ->delete(route('project-templates.destroy', $template)) ->assertNotFound(); $this->actingAs($user) ->get(route('project-templates.edit', $template)) ->assertNotFound(); } public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void { $user = User::factory()->create(); $sourceProject = Project::factory()->create(); $template = ProjectTemplate::factory()->create([ 'source_project_id' => $sourceProject->id, ]); Project::factory()->create([ 'project_template_id' => $template->id, ]); $this->syncGlobalPermissions($user, ['project:create']); $this->actingAs($user) ->patch(route('project-templates.update', $template), [ 'name' => 'Updated Template', 'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], ]) ->assertSessionHasErrors('confirm_linked_projects_update'); } public function test_updating_a_template_refreshes_the_baseline_and_preserves_slug_and_overrides(): void { $user = User::factory()->create(); $sourceProject = Project::factory()->create([ 'name' => 'Template Source', 'show_author' => false, 'show_story_points' => false, ]); TicketType::factory()->create([ 'project_id' => $sourceProject->id, 'name' => 'Feature', 'position' => 1, 'is_parent' => false, ]); $template = ProjectTemplate::factory()->create([ 'name' => 'Platform Template', 'slug' => 'platform-template', 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], 'configuration_snapshot' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ 'show_author' => false, 'show_story_points' => false, ], DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ [ 'name' => 'Bug', 'position' => 1, 'is_parent' => false, ], ], ], 'source_project_id' => $sourceProject->id, ]); $inheritingProject = Project::factory()->create([ 'project_template_id' => $template->id, 'show_author' => false, 'show_story_points' => false, ]); $overriddenProject = Project::factory()->create([ 'project_template_id' => $template->id, 'show_author' => true, 'show_story_points' => false, ]); TicketType::factory()->create([ 'project_id' => $inheritingProject->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false, ]); TicketType::factory()->create([ 'project_id' => $overriddenProject->id, 'name' => 'Custom', 'position' => 1, 'is_parent' => false, ]); $sourceProject->forceFill([ 'show_author' => true, 'show_story_points' => true, ])->save(); $this->syncGlobalPermissions($user, ['project:create']); $this->actingAs($user) ->patch(route('project-templates.update', $template), [ 'name' => 'Platform Template v2', 'slug' => 'should-not-change', 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], 'confirm_linked_projects_update' => true, ]) ->assertRedirect('/project-templates'); $template->refresh(); $inheritingProject->refresh(); $overriddenProject->refresh(); $this->assertSame('Platform Template v2', $template->name); $this->assertSame('platform-template', $template->slug); $this->assertSame([ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ 'show_author' => true, 'show_story_points' => true, ], DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ [ 'name' => 'Feature', 'position' => 1, 'is_parent' => false, ], ], ], $template->configuration_snapshot); $this->assertTrue($inheritingProject->show_author); $this->assertTrue($inheritingProject->show_story_points); $this->assertSame(['Feature'], $inheritingProject->ticketTypes()->pluck('name')->all()); $this->assertDatabaseHas('ticket_types', [ 'project_id' => $inheritingProject->id, 'project_template_id' => $template->id, 'name' => 'Feature', ]); $this->assertTrue($overriddenProject->show_author); $this->assertFalse($overriddenProject->show_story_points); $this->assertSame(['Custom'], $overriddenProject->ticketTypes()->pluck('name')->all()); } }