629 lines
38 KiB
PHP
629 lines
38 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Projects;
|
|
|
|
use App\Actions\Notifications\MatchNotificationFilters;
|
|
use App\Actions\Notifications\TicketStatusChangedPayload;
|
|
use App\Actions\ProjectBoard\DeleteTicketStatus;
|
|
use App\Actions\ProjectBoard\StoreTicketStatus;
|
|
use App\Actions\ProjectBoard\UpdateBoardColumn;
|
|
use App\Actions\Projects\CreateProject;
|
|
use App\Actions\Projects\CreateProjectTemplate;
|
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
|
use App\Actions\Projects\ProjectConfigurationIdentity;
|
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
|
use App\Actions\Projects\UpdateProjectTemplate;
|
|
use App\Actions\Projects\UpdateProjectTemplateLink;
|
|
use App\Models\BoardColumn;
|
|
use App\Models\NotificationAction;
|
|
use App\Models\Project;
|
|
use App\Models\ProjectTemplate;
|
|
use App\Models\RepositoryConnection;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketFieldDefinition;
|
|
use App\Models\TicketFieldValue;
|
|
use App\Models\TicketLink;
|
|
use App\Models\TicketLinkType;
|
|
use App\Models\TicketPriority;
|
|
use App\Models\TicketStatus;
|
|
use App\Models\TicketType;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
class ProjectTemplateIdentityTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_deleting_an_earlier_template_status_preserves_ticket_meaning(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$todo = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Doing', 'position' => 2]);
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 3]);
|
|
$template = $this->template($source, ['ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
$ticket = $this->ticket($project, $project->ticketStatuses()->where('name', 'Doing')->firstOrFail());
|
|
|
|
app(DeleteTicketStatus::class)->handle($source, $todo);
|
|
$this->refreshTemplate($template);
|
|
|
|
$this->assertSame('Doing', $ticket->fresh()->ticketStatus->name, 'Deleting Todo made a Doing ticket Done.');
|
|
}
|
|
|
|
public function test_inherited_transitions_map_to_correct_statuses_after_local_insertion(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
|
|
$second = BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
|
|
$todo = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
|
|
$done = TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 1, 'board_column_id' => $second->id]);
|
|
$template = $this->template($source, ['board_columns', 'ticket_statuses', 'status_transitions']);
|
|
$project = $this->linkedProject($template);
|
|
app(StoreTicketStatus::class)->handle($project, ['name' => 'Local', 'board_column_id' => $project->boardColumns()->where('name', 'First')->firstOrFail()->id]);
|
|
$todo->allowedTargetStatuses()->sync([$done->id]);
|
|
|
|
$this->refreshTemplate($template);
|
|
|
|
$projectTodo = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
|
|
$this->assertSame(['Done'], $projectTodo->allowedTargetStatuses()->pluck('name')->all(), 'Template transition targeted Local instead of Done.');
|
|
}
|
|
|
|
public function test_effective_configuration_includes_local_areas_absent_from_template(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$template = $this->template($source, ['project_settings']);
|
|
$project = $this->linkedProject($template);
|
|
TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]);
|
|
|
|
$state = app(ResolveProjectTemplateState::class)->handle($project->fresh());
|
|
|
|
$this->assertArrayHasKey('ticket_types', $state['effectiveConfiguration']);
|
|
}
|
|
|
|
public function test_private_baseline_tracks_column_reordering(): void
|
|
{
|
|
$project = app(CreateProject::class)->handle(['name' => 'Private', 'key' => 'PRIV'], User::factory()->create());
|
|
$first = BoardColumn::factory()->for($project)->create(['name' => 'First', 'position' => 1]);
|
|
$second = BoardColumn::factory()->for($project)->create(['name' => 'Second', 'position' => 2]);
|
|
|
|
app(UpdateBoardColumn::class)->handle($project, $second, ['name' => 'Second', 'position' => 1]);
|
|
|
|
$actual = app(DuplicateProjectConfiguration::class)->export($project->fresh(), ['board_columns']);
|
|
$snapshot = $project->fresh()->projectTemplate->configuration_snapshot;
|
|
$this->assertSame($actual['board_columns'], $snapshot['board_columns']);
|
|
}
|
|
|
|
public function test_used_local_status_in_earlier_column_requires_explicit_reset_replacement(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
|
|
$second = BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 1, 'board_column_id' => $second->id]);
|
|
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
$local = app(StoreTicketStatus::class)->handle($project, ['name' => 'Local', 'board_column_id' => $project->boardColumns()->where('name', 'First')->firstOrFail()->id]);
|
|
$this->ticket($project, $local);
|
|
$user = User::factory()->create();
|
|
$this->syncProjectPermissions($project, $user, ['admin']);
|
|
|
|
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), [
|
|
'areas' => ['ticket_statuses'],
|
|
])->assertSessionHasErrors('status_replacements.'.$local->id);
|
|
}
|
|
|
|
public function test_template_remains_editable_after_source_project_deletion(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$template = $this->template($source, ['project_settings']);
|
|
$source->delete();
|
|
$user = User::factory()->create();
|
|
$this->syncGlobalPermissions($user, ['create']);
|
|
|
|
$this->actingAs($user)->patch(route('project-templates.update', $template), [
|
|
'name' => 'Renamed',
|
|
'copy_areas' => ['project_settings'],
|
|
])->assertRedirect(route('project-templates.index'));
|
|
}
|
|
|
|
public function test_status_rename_propagates_stable_slug_behavior(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
|
|
$template = $this->template($source, ['ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
|
|
$status->update(['name' => 'Ready']);
|
|
$this->refreshTemplate($template);
|
|
|
|
$this->assertSame($status->fresh()->slug, $project->ticketStatuses()->firstOrFail()->slug);
|
|
}
|
|
|
|
public function test_local_column_reorder_does_not_remap_inherited_status_column(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
|
|
BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
|
|
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
|
|
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
$second = $project->boardColumns()->where('name', 'Second')->firstOrFail();
|
|
app(UpdateBoardColumn::class)->handle($project, $second, ['name' => 'Second', 'position' => 1]);
|
|
$status->update(['name' => 'Ready']);
|
|
|
|
$this->refreshTemplate($template);
|
|
|
|
$this->assertSame('First', $project->ticketStatuses()->firstOrFail()->boardColumn->name);
|
|
}
|
|
|
|
public function test_empty_template_and_project_creation_work_without_a_source_project(): void
|
|
{
|
|
$this->withoutVite();
|
|
$user = User::factory()->create();
|
|
$this->syncGlobalPermissions($user, ['create']);
|
|
$this->actingAs($user)->get(route('project-templates.create'))->assertOk();
|
|
$this->post(route('project-templates.store'), [
|
|
'name' => 'Empty template', 'slug' => 'empty-template',
|
|
'copy_areas' => DuplicateProjectConfiguration::copyableAreas(),
|
|
])->assertSessionHasNoErrors()->assertRedirect();
|
|
$template = ProjectTemplate::query()->where('slug', 'empty-template')->firstOrFail();
|
|
$this->assertNull($template->source_project_id);
|
|
$project = app(CreateProject::class)->handle([
|
|
'name' => 'From empty template', 'key' => 'EMPTY',
|
|
'creation_mode' => 'template', 'source_template_id' => $template->id,
|
|
], $user);
|
|
$this->assertSame(['Task', 'Release'], $project->ticketTypes()->pluck('name')->all());
|
|
$this->assertSame(['Medium'], $project->ticketPriorities()->pluck('name')->all());
|
|
$this->assertSame(['Backlog'], $project->ticketStatuses()->pluck('name')->all());
|
|
$this->assertSame([], $project->template_overrides);
|
|
$this->assertSame(0, $project->tickets()->count());
|
|
}
|
|
|
|
public function test_publishing_private_template_keeps_its_identity_and_links_the_source(): void
|
|
{
|
|
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], User::factory()->create());
|
|
$base = $project->projectTemplate;
|
|
$status = $project->ticketStatuses()->firstOrFail();
|
|
$ticket = $this->ticket($project, $status);
|
|
$template = app(CreateProjectTemplate::class)->handle($project, [
|
|
'name' => 'Published', 'slug' => $base->slug, 'copy_areas' => DuplicateProjectConfiguration::copyableAreas(),
|
|
]);
|
|
$this->assertSame($base->id, $template->id);
|
|
$this->assertSame($base->slug, $template->slug);
|
|
$this->assertTrue($template->is_published);
|
|
$this->assertNull($template->project_id);
|
|
$this->assertSame($template->id, $project->fresh()->project_template_id);
|
|
$configuration = $template->configuration_snapshot;
|
|
$configuration['ticket_statuses'][0]['name'] = 'Ready';
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
|
|
$this->assertSame('Ready', $ticket->fresh()->ticketStatus->name);
|
|
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
|
|
}
|
|
|
|
public function test_read_only_project_users_cannot_publish_and_relink_the_project(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$project = Project::factory()->create();
|
|
$this->syncGlobalPermissions($user, ['create']);
|
|
$this->syncProjectPermissions($project, $user, ['view']);
|
|
$this->actingAs($user)->post(route('projects.templates.store', $project), [
|
|
'name' => 'Forbidden', 'slug' => 'forbidden', 'copy_areas' => ['project_settings'],
|
|
])->assertForbidden();
|
|
$this->assertNull($project->fresh()->project_template_id);
|
|
}
|
|
|
|
public function test_name_only_update_keeps_the_snapshot_even_when_the_source_changed(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
|
|
$template = $this->template($source, ['ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
|
|
$snapshot = $template->configuration_snapshot;
|
|
$status->update(['name' => 'Source change']);
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => 'Only renamed']);
|
|
$this->assertSame($snapshot, $template->fresh()->configuration_snapshot);
|
|
$this->assertSame('Todo', $ticket->fresh()->ticketStatus->name);
|
|
}
|
|
|
|
public function test_referenced_template_status_removal_keeps_the_project_area_until_explicit_reset(): void
|
|
{
|
|
$this->withoutVite();
|
|
$user = User::factory()->create();
|
|
$configuration = app(ProjectConfigurationIdentity::class)->defaults();
|
|
$configuration['ticket_statuses'][] = ['configuration_key' => (string) Str::uuid(), 'name' => 'Done', 'position' => 2, 'board_column_key' => null];
|
|
$template = app(CreateProjectTemplate::class)->empty(['name' => 'Protected', 'slug' => 'protected', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration]);
|
|
$project = $this->linkedProject($template);
|
|
$todo = $project->ticketStatuses()->where('name', 'Backlog')->firstOrFail();
|
|
$ticket = Ticket::factory()->create(['project_id' => $project->id, 'ticket_status_id' => $todo->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id]);
|
|
$next = $configuration;
|
|
$next['ticket_statuses'] = [$configuration['ticket_statuses'][1]];
|
|
$next['ticket_statuses'][0]['position'] = 1;
|
|
$next['status_transitions'] = [];
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
|
|
$this->assertSame('Backlog', $ticket->fresh()->ticketStatus->name);
|
|
$this->assertContains('ticket_statuses', $project->fresh()->template_overrides);
|
|
$this->syncProjectPermissions($project, $user, ['admin']);
|
|
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses']])->assertSessionHasErrors('status_replacements.'.$todo->id);
|
|
$this->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses'], 'status_replacements' => [$todo->id => $next['ticket_statuses'][0]['configuration_key']]])->assertSessionHasNoErrors();
|
|
$this->assertSame('Done', $ticket->fresh()->ticketStatus->name);
|
|
$this->assertNotContains('ticket_statuses', $project->fresh()->template_overrides);
|
|
}
|
|
|
|
public function test_reset_can_create_a_new_template_status_and_use_it_as_replacement(): void
|
|
{
|
|
$this->withoutVite();
|
|
$source = Project::factory()->create();
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
|
|
$template = $this->template($source, ['ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
$status = $project->ticketStatuses()->firstOrFail();
|
|
$status->update(['name' => 'Local']);
|
|
$ticket = $this->ticket($project, $status);
|
|
$newKey = (string) Str::uuid();
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => ['ticket_statuses' => [['configuration_key' => $newKey, 'name' => 'New template status', 'board_column_key' => null, 'position' => 1]]]]);
|
|
$user = User::factory()->create();
|
|
$this->syncProjectPermissions($project, $user, ['admin']);
|
|
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses'], 'status_replacements' => [$status->id => $newKey]])->assertSessionHasNoErrors();
|
|
$this->assertSame('New template status', $ticket->fresh()->ticketStatus->name);
|
|
$this->assertSame($newKey, $ticket->fresh()->ticketStatus->configuration_key);
|
|
}
|
|
|
|
public function test_field_reordering_preserves_values_and_type_changes_with_values_stay_local(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number', 'position' => 1]);
|
|
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Cost', 'field_type' => 'number', 'position' => 2]);
|
|
$template = $this->template($source, ['ticket_fields']);
|
|
$project = $this->linkedProject($template);
|
|
$field = $project->ticketFieldDefinitions()->where('label', 'Estimate')->firstOrFail();
|
|
$status = TicketStatus::factory()->for($project)->create();
|
|
$ticket = $this->ticket($project, $status);
|
|
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
|
|
$next = $template->configuration_snapshot;
|
|
$next['ticket_fields'] = array_reverse($next['ticket_fields']);
|
|
$next['ticket_fields'][0]['position'] = 1;
|
|
$next['ticket_fields'][1]['position'] = 2;
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
|
|
$this->assertSame('Estimate', $value->fresh()->ticketFieldDefinition->label);
|
|
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
|
|
$next['ticket_fields'][1]['field_type'] = 'user';
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
|
|
$this->assertSame('number', $field->fresh()->field_type);
|
|
$this->assertEquals(8, $value->fresh()->value_number);
|
|
$this->assertContains('ticket_fields', $project->fresh()->template_overrides);
|
|
}
|
|
|
|
public function test_template_payload_rejects_duplicate_keys_foreign_references_and_unknown_fields(): void
|
|
{
|
|
$this->withoutVite();
|
|
$user = User::factory()->create();
|
|
$this->syncGlobalPermissions($user, ['create']);
|
|
$configuration = app(ProjectConfigurationIdentity::class)->defaults();
|
|
$configuration['ticket_types'][1]['configuration_key'] = $configuration['ticket_types'][0]['configuration_key'];
|
|
$configuration['ticket_statuses'][0]['board_column_key'] = (string) Str::uuid();
|
|
$configuration['ticket_priorities'][0]['project_id'] = 999;
|
|
$this->actingAs($user)->post(route('project-templates.store'), ['name' => 'Invalid', 'slug' => 'invalid', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration])->assertSessionHasErrors([
|
|
'configuration_snapshot.ticket_types.0.configuration_key', 'configuration_snapshot.ticket_priorities.0',
|
|
]);
|
|
unset($configuration['ticket_priorities'][0]['project_id']);
|
|
$configuration['ticket_types'][1]['configuration_key'] = (string) Str::uuid();
|
|
$this->post(route('project-templates.store'), ['name' => 'Invalid', 'slug' => 'invalid', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration])->assertSessionHasErrors('configuration_snapshot.ticket_statuses.0.board_column_key');
|
|
$this->assertSame(0, ProjectTemplate::query()->where('slug', 'invalid')->count());
|
|
}
|
|
|
|
public function test_identity_migration_keeps_existing_tickets_values_links_and_overrides(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
|
|
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number']);
|
|
$template = $this->template($source, ['ticket_statuses', 'ticket_fields']);
|
|
$project = $this->linkedProject($template);
|
|
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
|
|
$field = $project->ticketFieldDefinitions()->firstOrFail();
|
|
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
|
|
$statusId = $ticket->ticket_status_id;
|
|
$overrides = $project->fresh()->template_overrides;
|
|
$migration = require database_path('migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php');
|
|
$migration->down();
|
|
$legacy = $template->fresh()->configuration_snapshot;
|
|
$this->assertArrayNotHasKey('configuration_key', $legacy['ticket_fields'][0]);
|
|
$migration->up();
|
|
$this->assertSame($template->id, $project->fresh()->project_template_id);
|
|
$this->assertSame($statusId, $ticket->fresh()->ticket_status_id);
|
|
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
|
|
$this->assertEquals(8, $value->fresh()->value_number);
|
|
$this->assertSame($overrides, $project->fresh()->template_overrides);
|
|
$snapshot = $template->fresh()->configuration_snapshot;
|
|
$this->assertSame($snapshot['ticket_statuses'][0]['configuration_key'], $ticket->fresh()->ticketStatus->configuration_key);
|
|
$this->assertSame($snapshot['ticket_fields'][0]['configuration_key'], $field->fresh()->configuration_key);
|
|
}
|
|
|
|
public function test_status_reset_transfers_local_transitions_and_preserves_their_override(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 2]);
|
|
$template = $this->template($source, ['ticket_statuses', 'status_transitions']);
|
|
$project = $this->linkedProject($template);
|
|
$todo = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
|
|
$done = $project->ticketStatuses()->where('name', 'Done')->firstOrFail();
|
|
$local = TicketStatus::factory()->for($project)->create(['name' => 'Local', 'position' => 3]);
|
|
$todo->allowedTargetStatuses()->sync([$local->id]);
|
|
$local->allowedTargetStatuses()->sync([$done->id]);
|
|
$project->fresh()->forceFill(['template_overrides' => ['ticket_statuses', 'status_transitions']])->saveQuietly();
|
|
$ticket = $this->ticket($project, $local);
|
|
$expectedOverrides = array_values(array_diff($project->fresh()->template_overrides, ['ticket_statuses']));
|
|
app(UpdateProjectTemplateLink::class)->reset($project->fresh(), ['ticket_statuses'], [$local->id => $todo->configuration_key]);
|
|
$this->assertSame($todo->id, $ticket->fresh()->ticket_status_id);
|
|
$this->assertSame([$done->id], $todo->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
|
|
$this->assertSame($expectedOverrides, $project->fresh()->template_overrides);
|
|
}
|
|
|
|
public function test_deleting_a_template_creates_private_baselines_without_recreating_configuration(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->syncGlobalPermissions($user, ['create']);
|
|
$template = app(CreateProjectTemplate::class)->empty(['name' => 'Shared', 'slug' => 'shared', 'copy_areas' => DuplicateProjectConfiguration::copyableAreas()]);
|
|
$project = $this->linkedProject($template);
|
|
$otherProject = $this->linkedProject($template);
|
|
$status = $project->ticketStatuses()->firstOrFail();
|
|
$ticket = $this->ticket($project, $status);
|
|
$snapshot = app(DuplicateProjectConfiguration::class)->export($project, DuplicateProjectConfiguration::copyableAreas());
|
|
$this->actingAs($user)->delete(route('project-templates.destroy', $template))->assertRedirect();
|
|
$base = $project->fresh()->projectTemplate;
|
|
$this->assertNotNull($base);
|
|
$this->assertFalse($base->is_published);
|
|
$this->assertSame($project->id, $base->project_id);
|
|
$this->assertSame($snapshot, $base->configuration_snapshot);
|
|
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
|
|
$this->assertFalse($otherProject->fresh()->projectTemplate->is_published);
|
|
$this->assertNotSame($base->id, $otherProject->fresh()->project_template_id);
|
|
}
|
|
|
|
public function test_private_base_slug_does_not_collide_with_published_template_slugs(): void
|
|
{
|
|
ProjectTemplate::factory()->create(['slug' => 'orig-base-template']);
|
|
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], User::factory()->create());
|
|
$this->assertSame('orig-base-template-2', $project->projectTemplate->slug);
|
|
}
|
|
|
|
public function test_reordering_types_priorities_and_links_preserves_ticket_references(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
|
|
foreach (['Task', 'Bug'] as $index => $name) {
|
|
TicketType::factory()->for($source)->create(['name' => $name, 'position' => $index + 1]);
|
|
}
|
|
foreach (['High', 'Low'] as $index => $name) {
|
|
TicketPriority::factory()->for($source)->create(['name' => $name, 'position' => $index + 1]);
|
|
}
|
|
foreach (['Blocks', 'Relates'] as $index => $label) {
|
|
TicketLinkType::factory()->for($source)->create(['label' => $label, 'position' => $index + 1]);
|
|
}
|
|
$template = $this->template($source, ['ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_link_types']);
|
|
$project = $this->linkedProject($template);
|
|
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
|
|
$link = TicketLink::factory()->create(['source_ticket_id' => $ticket->id, 'ticket_link_type_id' => $project->ticketLinkTypes()->firstOrFail()->id]);
|
|
$next = $template->configuration_snapshot;
|
|
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
|
|
$next[$area] = array_reverse($next[$area]);
|
|
foreach ($next[$area] as $index => &$item) {
|
|
$item['position'] = $index + 1;
|
|
}
|
|
unset($item);
|
|
}
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
|
|
$this->assertSame('Task', $ticket->fresh()->ticketType->name);
|
|
$this->assertSame('High', $ticket->fresh()->ticketPriority->name);
|
|
$this->assertSame('Blocks', $link->fresh()->ticketLinkType->label);
|
|
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
|
|
array_pop($next[$area]);
|
|
}
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
|
|
$this->assertSame('Task', $ticket->fresh()->ticketType->name);
|
|
$this->assertSame('High', $ticket->fresh()->ticketPriority->name);
|
|
$this->assertSame('Blocks', $link->fresh()->ticketLinkType->label);
|
|
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
|
|
$this->assertContains($area, $project->fresh()->template_overrides);
|
|
}
|
|
}
|
|
|
|
public function test_notification_status_filters_use_the_linked_project_status_ids(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$status = TicketStatus::factory()->for($source)->create(['name' => 'Ready']);
|
|
NotificationAction::factory()->for($source)->create(['filters' => [['attribute' => 'status.to.id', 'operator' => 'equals', 'value' => (string) $status->id]]]);
|
|
$template = $this->template($source, ['ticket_statuses', 'notification_actions']);
|
|
$this->assertSame($status->configuration_key, $template->configuration_snapshot['notification_actions'][0]['filters'][0]['value']);
|
|
$project = $this->linkedProject($template);
|
|
$target = $project->ticketStatuses()->firstOrFail();
|
|
$notification = $project->notificationActions()->firstOrFail();
|
|
$this->assertSame((string) $target->id, $notification->filters[0]['value']);
|
|
$payload = new TicketStatusChangedPayload($project->id, $project->key, $project->name, 1, $project->key.'-1', 'Example', 'https://example.test/ticket', null, null, $target->id, $target->name, $target->id, $target->name, now());
|
|
$this->assertTrue(app(MatchNotificationFilters::class)->handle($notification->filters, $payload));
|
|
$exported = app(DuplicateProjectConfiguration::class)->export($project, ['notification_actions']);
|
|
$this->assertSame($status->configuration_key, $exported['notification_actions'][0]['filters'][0]['value']);
|
|
}
|
|
|
|
public function test_column_replacement_moves_inherited_statuses_without_losing_tickets(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
$column = BoardColumn::factory()->for($source)->create(['name' => 'Old column', 'position' => 1]);
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Ready', 'position' => 1, 'board_column_id' => $column->id]);
|
|
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
$status = $project->ticketStatuses()->firstOrFail();
|
|
$ticket = $this->ticket($project, $status);
|
|
$next = $template->configuration_snapshot;
|
|
$key = (string) Str::uuid();
|
|
$next['board_columns'] = [['configuration_key' => $key, 'name' => 'New column', 'position' => 1]];
|
|
$next['ticket_statuses'][0]['board_column_key'] = $key;
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
|
|
$this->assertSame('New column', $ticket->fresh()->ticketStatus->boardColumn->name);
|
|
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
|
|
$this->assertNotContains('board_columns', $project->fresh()->template_overrides);
|
|
$this->assertSame(['New column'], $project->boardColumns()->pluck('name')->all());
|
|
}
|
|
|
|
public function test_publishing_private_template_via_http_preserves_its_slug(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->syncGlobalPermissions($user, ['create']);
|
|
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], $user);
|
|
$base = $project->projectTemplate;
|
|
$url = route('projects.templates.store', $project);
|
|
$this->actingAs($user)->post($url, ['name' => 'Published', 'slug' => 'different', 'copy_areas' => ['project_settings']])->assertSessionHasErrors('slug');
|
|
$this->post($url, ['name' => 'Published', 'slug' => $base->slug, 'copy_areas' => ['project_settings']])->assertSessionHasNoErrors()->assertRedirect();
|
|
$this->assertSame($base->id, $project->fresh()->project_template_id);
|
|
$this->assertTrue($base->fresh()->is_published);
|
|
$this->assertSame($base->slug, $base->fresh()->slug);
|
|
}
|
|
|
|
public function test_identity_migration_keeps_duplicate_field_labels_distinct(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
foreach ([1, 2] as $position) {
|
|
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number', 'position' => $position]);
|
|
}
|
|
$template = $this->template($source, ['ticket_fields']);
|
|
$project = $this->linkedProject($template);
|
|
$field = $project->ticketFieldDefinitions()->firstOrFail();
|
|
$ticket = $this->ticket($project, TicketStatus::factory()->for($project)->create());
|
|
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
|
|
$migration = require database_path('migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php');
|
|
$migration->down();
|
|
$legacy = $template->fresh()->configuration_snapshot;
|
|
$this->assertArrayNotHasKey('configuration_key', $legacy['ticket_fields'][0]);
|
|
$migration->up();
|
|
$configuration = $template->fresh()->configuration_snapshot;
|
|
$this->assertNotSame($configuration['ticket_fields'][0]['configuration_key'], $configuration['ticket_fields'][1]['configuration_key']);
|
|
$this->assertSame($configuration['ticket_fields'][0]['configuration_key'], $field->fresh()->configuration_key);
|
|
$configuration['ticket_fields'][0]['label'] = 'First estimate';
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
|
|
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
|
|
$this->assertSame('First estimate', $value->fresh()->ticketFieldDefinition->label);
|
|
$this->assertEquals(8, $value->fresh()->value_number);
|
|
}
|
|
|
|
public function test_stale_template_ownership_does_not_restore_the_old_link(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
|
|
$template = $this->template($source, ['ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
$other = app(CreateProjectTemplate::class)->empty(['name' => 'Other', 'slug' => 'other', 'copy_areas' => ['project_settings']]);
|
|
app(UpdateProjectTemplateLink::class)->switch($project, $other);
|
|
$status = TicketStatus::factory()->for($project)->create(['project_template_id' => $template->id, 'position' => 2]);
|
|
$this->assertSame($other->id, $status->project_template_id);
|
|
$this->assertSame($project->id, $status->project_id);
|
|
}
|
|
|
|
public function test_template_update_skips_a_project_that_changed_templates_before_its_lock(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
|
|
$template = $this->template($source, ['ticket_statuses']);
|
|
$project = $this->linkedProject($template);
|
|
$other = app(CreateProjectTemplate::class)->empty(['name' => 'Other', 'slug' => 'other', 'copy_areas' => ['project_settings']]);
|
|
$changed = false;
|
|
Project::retrieved(function (Project $selected) use ($project, $other, &$changed): void {
|
|
if (! $changed && $selected->id === $project->id) {
|
|
$changed = true;
|
|
DB::table('projects')->where('id', $project->id)->update(['project_template_id' => $other->id]);
|
|
}
|
|
});
|
|
|
|
try {
|
|
$configuration = $template->configuration_snapshot;
|
|
$configuration['ticket_statuses'][0]['name'] = 'Changed on old template';
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
|
|
} finally {
|
|
Event::forget('eloquent.retrieved: '.Project::class);
|
|
}
|
|
|
|
$this->assertTrue($changed);
|
|
$this->assertSame($other->id, $project->fresh()->project_template_id);
|
|
$this->assertSame(['Todo'], $project->ticketStatuses()->pluck('name')->all());
|
|
}
|
|
|
|
public function test_repository_replacement_and_swapping_preserve_valid_connections(): void
|
|
{
|
|
$source = Project::factory()->create();
|
|
RepositoryConnection::factory()->for($source)->create(['provider_repository_id' => '1', 'base_url' => 'https://git.example.test']);
|
|
RepositoryConnection::factory()->for($source)->create(['provider_repository_id' => '2', 'base_url' => 'https://git.example.test']);
|
|
$template = $this->template($source, ['repository_connections']);
|
|
$project = $this->linkedProject($template);
|
|
$original = $project->repositoryConnections()->get()->keyBy('configuration_key');
|
|
$configuration = $template->configuration_snapshot;
|
|
$configuration['repository_connections'][0]['provider_repository_id'] = '2';
|
|
$configuration['repository_connections'][1]['provider_repository_id'] = '1';
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
|
|
foreach ($configuration['repository_connections'] as $entry) {
|
|
$connection = $project->repositoryConnections()->where('configuration_key', $entry['configuration_key'])->firstOrFail();
|
|
$this->assertSame($original[$entry['configuration_key']]->id, $connection->id);
|
|
$this->assertSame($entry['provider_repository_id'], $connection->provider_repository_id);
|
|
}
|
|
|
|
$configuration['repository_connections'][0]['configuration_key'] = (string) Str::uuid();
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
|
|
$this->assertSame(2, $project->repositoryConnections()->count());
|
|
$this->assertEqualsCanonicalizing(['1', '2'], $project->repositoryConnections()->pluck('provider_repository_id')->all());
|
|
}
|
|
|
|
public function test_template_configuration_rejects_duplicate_repository_connections(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->syncGlobalPermissions($user, ['create']);
|
|
$source = Project::factory()->create();
|
|
RepositoryConnection::factory()->for($source)->create();
|
|
$configuration = app(DuplicateProjectConfiguration::class)->export($source, ['repository_connections']);
|
|
$configuration['repository_connections'][] = [
|
|
...$configuration['repository_connections'][0], 'configuration_key' => (string) Str::uuid(),
|
|
];
|
|
$this->actingAs($user)->post(route('project-templates.store'), [
|
|
'name' => 'Duplicate repositories', 'slug' => 'duplicate-repositories',
|
|
'copy_areas' => ['repository_connections'], 'configuration_snapshot' => $configuration,
|
|
])->assertSessionHasErrors('configuration_snapshot.repository_connections.1.provider_repository_id');
|
|
$this->assertDatabaseMissing('project_templates', ['slug' => 'duplicate-repositories']);
|
|
}
|
|
|
|
private function template(Project $source, array $areas): ProjectTemplate
|
|
{
|
|
return ProjectTemplate::factory()->create([
|
|
'source_project_id' => $source->id,
|
|
'is_published' => true,
|
|
'copy_areas' => $areas,
|
|
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($source, $areas),
|
|
]);
|
|
}
|
|
|
|
private function linkedProject(ProjectTemplate $template): Project
|
|
{
|
|
$project = Project::factory()->create(['project_template_id' => $template->id]);
|
|
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot);
|
|
|
|
return $project->fresh();
|
|
}
|
|
|
|
private function refreshTemplate(ProjectTemplate $template): void
|
|
{
|
|
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'copy_areas' => $template->copy_areas, 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, $template->copy_areas)]);
|
|
}
|
|
|
|
private function ticket(Project $project, TicketStatus $status): Ticket
|
|
{
|
|
return Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'ticket_type_id' => ($project->ticketTypes()->first() ?? TicketType::factory()->for($project)->create())->id,
|
|
'ticket_priority_id' => ($project->ticketPriorities()->first() ?? TicketPriority::factory()->for($project)->create())->id,
|
|
'ticket_status_id' => $status->id,
|
|
'ticket_key' => $project->key.'-1',
|
|
]);
|
|
}
|
|
}
|