fix: make project template inheritance granular and safe
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

This commit is contained in:
Nico Schröder
2026-09-08 10:25:52 +00:00
committed by Twyco
parent fed6a282a4
commit 9895fc36db
50 changed files with 1504 additions and 624 deletions
@@ -5,6 +5,7 @@ namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
@@ -272,6 +273,9 @@ class ProjectBoardControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
$otherProject = Project::factory()->create();
$sharedTemplate = ProjectTemplate::factory()->create();
$project->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly();
$otherProject->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly();
app(InitializeProjectTicketMetadata::class)->handle($project);
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
@@ -2,8 +2,8 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\ProjectTemplate;
@@ -54,7 +54,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
->where('boardColumns.0.name', 'Todo')
->where('ticketStatuses.0.name', 'Backlog')
->where('templateState.templateName', 'Starter Workflow')
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW),
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_COLUMNS)
->where('templateState.inheritedAreas.1.value', DuplicateProjectConfiguration::COPY_TICKET_STATUSES)
->where('templateState.inheritedAreas.2.value', DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS),
);
}
@@ -308,6 +310,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
$otherProject = Project::factory()->create([
'key' => 'EXT',
]);
$sharedTemplate = ProjectTemplate::factory()->create();
$project->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly();
$otherProject->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly();
$this->syncProjectPermissions($project, $user, ['edit']);
$sourceStatus = TicketStatus::factory()->for($project)->create([
@@ -410,6 +410,8 @@ class ProjectControllerTest extends TestCase
],
]);
$response->assertRedirect()->assertSessionHasNoErrors();
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
@@ -243,6 +243,13 @@ class ProjectTemplateControllerTest extends TestCase
'position' => 1,
'is_parent' => false,
]);
$inheritingProject->refresh()->forceFill(['template_overrides' => []])->saveQuietly();
$overriddenProject->refresh()->forceFill([
'template_overrides' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
])->saveQuietly();
$sourceProject->forceFill([
'show_author' => true,
@@ -0,0 +1,263 @@
<?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',
]);
}
}
@@ -2,8 +2,8 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket;
@@ -55,6 +55,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$project->update([
'project_template_id' => $template->id,
'show_story_points' => false,
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
]);
$this->syncProjectPermissions($project, $user, ['edit']);