Files
KanbanGit/tests/Feature/Projects/ProjectTemplateOverrideTest.php
T
Nico Schröder 9895fc36db
CI/CD / test_and_check_app (push) Failing after 3m8s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Failing after 3m8s
CI/CD / Publish immutable image (pull_request) Skipped
fix: make project template inheritance granular and safe
2026-09-08 10:25:52 +00:00

264 lines
12 KiB
PHP

<?php
namespace Tests\Feature\Projects;
use App\Actions\Projects\CreateProject;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\UpdateProjectTemplate;
use App\Actions\Projects\UpdateProjectTemplateLink;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectTemplateOverrideTest extends TestCase
{
use RefreshDatabase;
public function test_template_updates_preserve_status_ids_and_existing_tickets(): 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();
$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<string> $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',
]);
}
}