fix: make project template inheritance granular and safe
This commit is contained in:
@@ -169,6 +169,9 @@ class ProjectApiTest extends TestCase
|
||||
'position' => 2,
|
||||
'is_parent' => false,
|
||||
]);
|
||||
$project->forceFill([
|
||||
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||
])->saveQuietly();
|
||||
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
|
||||
$this->assertSame('lgcy-base-template', $template->slug);
|
||||
}
|
||||
|
||||
public function test_metadata_migration_moves_existing_configuration_to_the_project_template_and_preserves_snapshot(): void
|
||||
public function test_metadata_migration_moves_existing_configuration_and_preserves_published_template_links(): void
|
||||
{
|
||||
Artisan::call('migrate:fresh', [
|
||||
'--path' => [
|
||||
@@ -131,6 +131,42 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('projects')->insert([
|
||||
'name' => 'Linked Project',
|
||||
'key' => 'LINK',
|
||||
'show_author' => 1,
|
||||
'show_story_points' => 0,
|
||||
'project_template_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$linkedProjectId = (int) DB::table('projects')->where('key', 'LINK')->value('id');
|
||||
$publishedSnapshot = [
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||
'show_author' => false,
|
||||
'show_story_points' => true,
|
||||
],
|
||||
];
|
||||
$publishedTemplateId = DB::table('project_templates')->insertGetId([
|
||||
'project_id' => null,
|
||||
'source_project_id' => $projectId,
|
||||
'name' => 'Published Template',
|
||||
'slug' => 'published-template',
|
||||
'is_published' => true,
|
||||
'copy_areas' => json_encode(
|
||||
[DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||
JSON_THROW_ON_ERROR,
|
||||
),
|
||||
'configuration_snapshot' => json_encode($publishedSnapshot, JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('projects')->where('id', $linkedProjectId)->update([
|
||||
'project_template_id' => $publishedTemplateId,
|
||||
]);
|
||||
|
||||
Artisan::call('migrate', [
|
||||
'--path' => 'database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php',
|
||||
]);
|
||||
@@ -142,5 +178,18 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
|
||||
|
||||
$this->assertSame($project->project_template_id, $ticketType->project_template_id);
|
||||
$this->assertSame(['Bug'], array_column($snapshot[DuplicateProjectConfiguration::COPY_TICKET_TYPES], 'name'));
|
||||
$this->assertSame(
|
||||
$publishedTemplateId,
|
||||
DB::table('projects')->where('id', $linkedProjectId)->value('project_template_id'),
|
||||
);
|
||||
$this->assertSame(
|
||||
$publishedSnapshot,
|
||||
json_decode(
|
||||
(string) DB::table('project_templates')->where('id', $publishedTemplateId)->value('configuration_snapshot'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use App\Actions\Notifications\DeliverNotificationAction;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Enums\NotificationDeliveryStatus;
|
||||
use App\Mcp\Servers\KanbanGitServer;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
@@ -247,6 +247,9 @@ class KanbanGitMcpTest extends TestCase
|
||||
'position' => 1,
|
||||
'is_parent' => false,
|
||||
]);
|
||||
$project->forceFill([
|
||||
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||
])->saveQuietly();
|
||||
$status = $project->ticketStatuses()->firstOrFail();
|
||||
$ticket = $this->ticket($project, $status, [
|
||||
'ticket_key' => 'CORE-1',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user