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', ]); } }