create(); $sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]); $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]); $project = Project::factory()->create(); $status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]); $project->forceFill(['project_template_id' => $template->id])->saveQuietly(); $ticket = $this->createTicket($project, $status); $sourceStatus->update(['name' => 'Ready']); app(UpdateProjectTemplate::class)->handle($template, [ 'name' => $template->name, 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], ]); $this->assertSame('Ready', $status->fresh()->name); $this->assertSame($status->id, $ticket->fresh()->ticket_status_id); $this->assertDatabaseHas('tickets', ['id' => $ticket->id]); } public function test_projects_created_from_a_template_start_with_inherited_areas(): void { $sourceProject = Project::factory()->create(); $sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]); $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]); $project = app(CreateProject::class)->handle([ 'name' => 'Inherited project', 'key' => 'INHR', 'creation_mode' => 'template', 'source_template_id' => $template->id, ], User::factory()->create()); $this->assertSame([], $project->template_overrides); $sourceStatus->update(['name' => 'Ready']); app(UpdateProjectTemplate::class)->handle($template, [ 'name' => $template->name, 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], ]); $this->assertSame(['Ready'], $project->ticketStatuses()->pluck('name')->all()); } public function test_local_override_remains_detached_even_when_it_temporarily_matches_the_template(): void { $sourceProject = Project::factory()->create(); $sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]); $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]); $project = Project::factory()->create(); $status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]); $project->forceFill(['project_template_id' => $template->id])->saveQuietly(); $status->update(['name' => 'Project only']); $status->update(['name' => 'Todo']); $this->assertContains( DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $project->fresh()->template_overrides, ); $sourceStatus->update(['name' => 'Ready']); app(UpdateProjectTemplate::class)->handle($template, [ 'name' => $template->name, 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], ]); $this->assertSame('Todo', $status->fresh()->name); $this->assertContains( DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $project->fresh()->template_overrides, ); } public function test_reset_requires_and_applies_a_replacement_for_used_local_statuses(): void { $user = User::factory()->create(); $sourceProject = Project::factory()->create(); TicketStatus::factory()->for($sourceProject)->create(['name' => 'Template status', 'position' => 1]); $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]); $project = Project::factory()->create([ 'project_template_id' => $template->id, 'template_overrides' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], ]); $replacementStatus = TicketStatus::factory()->for($project)->create(['name' => 'Old template status', 'position' => 1]); $localStatus = TicketStatus::factory()->for($project)->create(['name' => 'Local status', 'position' => 2]); $ticket = $this->createTicket($project, $localStatus); $this->syncProjectPermissions($project, $user, ['admin']); $url = route('projects.settings.general.reset-template', $project); $this->actingAs($user)->from($url)->post($url, [ 'areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], ])->assertSessionHasErrors("status_replacements.{$localStatus->id}"); $this->actingAs($user)->from($url)->post($url, [ 'areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], 'status_replacements' => [$localStatus->id => $replacementStatus->id], ])->assertSessionHasNoErrors(); $this->assertDatabaseMissing('ticket_statuses', ['id' => $localStatus->id]); $this->assertSame($replacementStatus->id, $ticket->fresh()->ticket_status_id); $this->assertNotContains( DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $project->fresh()->template_overrides, ); } public function test_reset_clears_only_the_selected_override_area(): void { $sourceProject = Project::factory()->create(['show_author' => true, 'show_story_points' => true]); TicketType::factory()->for($sourceProject)->create(['name' => 'Template type', 'position' => 1]); $template = $this->createTemplate($sourceProject, [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ]); $project = Project::factory()->create([ 'project_template_id' => $template->id, 'show_author' => false, 'show_story_points' => false, 'template_overrides' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], ]); TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]); app(UpdateProjectTemplateLink::class)->reset( $project->load('projectTemplate'), [DuplicateProjectConfiguration::COPY_TICKET_TYPES], ); $this->assertFalse($project->fresh()->show_author); $this->assertSame(['Template type'], $project->ticketTypes()->pluck('name')->all()); $this->assertSame( [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], $project->fresh()->template_overrides, ); } public function test_new_template_areas_do_not_replace_existing_local_configuration(): void { $sourceProject = Project::factory()->create(); TicketType::factory()->for($sourceProject)->create(['name' => 'Template type', 'position' => 1]); $template = $this->createTemplate($sourceProject, [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, ]); $project = Project::factory()->create(); TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]); $project->forceFill(['project_template_id' => $template->id])->saveQuietly(); app(UpdateProjectTemplate::class)->handle($template, [ 'name' => $template->name, 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], ]); $this->assertSame(['Local type'], $project->ticketTypes()->pluck('name')->all()); $this->assertContains( DuplicateProjectConfiguration::COPY_TICKET_TYPES, $project->fresh()->template_overrides, ); } public function test_template_updates_preserve_custom_field_values(): void { $sourceProject = Project::factory()->create(); $sourceField = TicketFieldDefinition::factory()->for($sourceProject)->create([ 'field_type' => 'number', 'label' => 'Estimate', 'position' => 1, ]); $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_FIELDS]); $project = Project::factory()->create(); $field = TicketFieldDefinition::factory()->for($project)->create([ 'field_type' => 'number', 'label' => 'Estimate', 'position' => 1, ]); $project->forceFill(['project_template_id' => $template->id])->saveQuietly(); $status = TicketStatus::factory()->for($project)->create(); $ticket = $this->createTicket($project, $status); $fieldValue = TicketFieldValue::factory()->create([ 'ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8, ]); $sourceField->update(['label' => 'Story points']); app(UpdateProjectTemplate::class)->handle($template, [ 'name' => $template->name, 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_FIELDS], ]); $this->assertSame('Story points', $field->fresh()->label); $this->assertSame($field->id, $fieldValue->fresh()->ticket_field_definition_id); $this->assertEquals(8, $fieldValue->fresh()->value_number); } public function test_removing_a_board_column_never_deletes_its_statuses_or_tickets(): void { $project = Project::factory()->create(); $column = BoardColumn::factory()->for($project)->create(); $status = TicketStatus::factory()->for($project)->create([ 'board_column_id' => $column->id, ]); $ticket = $this->createTicket($project, $status); $blockedAreas = app(DuplicateProjectConfiguration::class)->apply($project, [ DuplicateProjectConfiguration::COPY_BOARD_COLUMNS => [], ]); $this->assertSame([DuplicateProjectConfiguration::COPY_BOARD_COLUMNS], $blockedAreas); $this->assertDatabaseHas('board_columns', ['id' => $column->id]); $this->assertDatabaseHas('ticket_statuses', ['id' => $status->id]); $this->assertSame($status->id, $ticket->fresh()->ticket_status_id); } /** @param list $areas */ private function createTemplate(Project $sourceProject, array $areas): ProjectTemplate { return ProjectTemplate::factory()->create([ 'source_project_id' => $sourceProject->id, 'copy_areas' => $areas, 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($sourceProject, $areas), ]); } private function createTicket(Project $project, TicketStatus $status): Ticket { $type = TicketType::factory()->for($project)->create(); $priority = TicketPriority::factory()->for($project)->create(); return Ticket::factory()->create([ 'project_id' => $project->id, 'ticket_status_id' => $status->id, 'ticket_type_id' => $type->id, 'ticket_priority_id' => $priority->id, 'ticket_key' => $project->key.'-1', ]); } }