Merge pull request 'Model ticket link counterparts explicitly' (#81) from koda/ticket-link-counterparts-v2 into main
linter / quality (push) Successful in 1m30s
tests / ci (8.4) (push) Successful in 2m7s

Reviewed-on: OpenSource/KanbanGit#81
Reviewed-by: Twyco <twyco@icloud.com>
This commit was merged in pull request #81.
This commit is contained in:
2026-05-24 10:20:02 +00:00
24 changed files with 380 additions and 67 deletions
@@ -12,16 +12,18 @@ class StoreTicketLinkType
use ReordersProjectMetadata;
/**
* @param array{name: string, position?: int|null} $validated
* @param array{name: string, counterpart_name: string, position?: int|null} $validated
*/
public function handle(Project $project, array $validated): TicketLinkType
{
$name = Arr::get($validated, 'name');
$counterpartName = Arr::get($validated, 'counterpart_name');
$position = Arr::get($validated, 'position');
return DB::transaction(function () use ($project, $position, $name): TicketLinkType {
return DB::transaction(function () use ($project, $position, $name, $counterpartName): TicketLinkType {
$ticketLinkType = $project->ticketLinkTypes()->create([
'label' => $name,
'counterpart_label' => $counterpartName,
'position' => $project->ticketLinkTypes()->count() + 1,
]);
@@ -12,16 +12,18 @@ class UpdateTicketLinkType
use ReordersProjectMetadata;
/**
* @param array{name: string, position: int} $validated
* @param array{name: string, counterpart_name: string, position: int} $validated
*/
public function handle(Project $project, TicketLinkType $ticketLinkType, array $validated): TicketLinkType
{
$name = Arr::get($validated, 'name');
$counterpartName = Arr::get($validated, 'counterpart_name');
$position = Arr::get($validated, 'position');
DB::transaction(function () use ($project, $name, $position, $ticketLinkType): void {
DB::transaction(function () use ($project, $name, $counterpartName, $position, $ticketLinkType): void {
$ticketLinkType->update([
'label' => $name,
'counterpart_label' => $counterpartName,
]);
$this->reorderModels(
@@ -23,13 +23,15 @@ class DeleteProjectTicketLink
404,
);
$linkedTicket = $ticketLink->source_ticket_id === $ticket->id
$isSourceTicket = $ticketLink->source_ticket_id === $ticket->id;
$linkedTicket = $isSourceTicket
? $ticketLink->targetTicket
: $ticketLink->sourceTicket;
$label = $ticketLink->ticketLinkType->label;
$label = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
$counterpartLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
DB::transaction(function () use ($label, $linkedTicket, $user, $ticket, $ticketLink): void {
DB::transaction(function () use ($label, $counterpartLabel, $linkedTicket, $user, $ticket, $ticketLink): void {
$ticketLink->delete();
$this->createTicketWorklogEntry->handle(
@@ -47,10 +49,10 @@ class DeleteProjectTicketLink
$linkedTicket,
$user,
TicketWorklogEntryType::LinkRemoved,
"Removed {$label} link to {$ticket->ticket_key}.",
"Removed {$counterpartLabel} link to {$ticket->ticket_key}.",
[
'linked_ticket_key' => $ticket->ticket_key,
'ticket_link_type' => $label,
'ticket_link_type' => $counterpartLabel,
],
);
});
+38 -25
View File
@@ -28,29 +28,53 @@ class StoreProjectTicketLink
->where('project_id', $project->id)
->findOrFail($linkedTicketId);
[$sourceTicketId, $targetTicketId] = $this->normalizeTicketPair($ticket->id, $linkedTicket->id);
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
Ticket::query()
->whereKey([$ticket->id, $linkedTicket->id])
->orderBy('id')
->lockForUpdate()
->get(['id']);
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $sourceTicketId, $targetTicketId, $ticket): TicketLink {
$ticketLink = TicketLink::query()->updateOrCreate(
[
'source_ticket_id' => $sourceTicketId,
'target_ticket_id' => $targetTicketId,
],
[
$ticketLink = TicketLink::query()
->where(function ($query) use ($ticket, $linkedTicket): void {
$query
->where('source_ticket_id', $ticket->id)
->where('target_ticket_id', $linkedTicket->id);
})
->orWhere(function ($query) use ($ticket, $linkedTicket): void {
$query
->where('source_ticket_id', $linkedTicket->id)
->where('target_ticket_id', $ticket->id);
})
->first();
if ($ticketLink === null) {
$ticketLink = TicketLink::query()->create([
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId,
],
);
]);
} else {
$ticketLink->update([
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId,
]);
}
$ticketLink->load('ticketLinkType');
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
$this->createTicketWorklogEntry->handle(
$ticket,
$user,
TicketWorklogEntryType::LinkUpdated,
"Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.",
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
[
'linked_ticket_key' => $linkedTicket->ticket_key,
'ticket_link_type' => $ticketLink->ticketLinkType->label,
'ticket_link_type' => $sourceLabel,
],
);
@@ -58,25 +82,14 @@ class StoreProjectTicketLink
$linkedTicket,
$user,
TicketWorklogEntryType::LinkUpdated,
"Linked {$ticket->ticket_key} as {$ticketLink->ticketLinkType->label}.",
"Linked {$ticket->ticket_key} as {$targetLabel}.",
[
'linked_ticket_key' => $ticket->ticket_key,
'ticket_link_type' => $ticketLink->ticketLinkType->label,
'ticket_link_type' => $targetLabel,
],
);
return $ticketLink;
});
}
/**
* @return array{0: int, 1: int}
*/
private function normalizeTicketPair(int $firstTicketId, int $secondTicketId): array
{
return [
min($firstTicketId, $secondTicketId),
max($firstTicketId, $secondTicketId),
];
}
}
@@ -22,10 +22,12 @@ use App\Http\Requests\DestroyProjectTicketTypeRequest;
use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreProjectTicketTypeRequest;
use App\Http\Requests\StoreTicketFieldDefinitionRequest;
use App\Http\Requests\StoreTicketLinkTypeRequest;
use App\Http\Requests\UpdateProjectNamedSettingRequest;
use App\Http\Requests\UpdateProjectTicketMetadataSettings as UpdateProjectTicketMetadataSettingsRequest;
use App\Http\Requests\UpdateProjectTicketTypeRequest;
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
use App\Http\Requests\UpdateTicketLinkTypeRequest;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TicketFieldDefinitionResource;
use App\Http\Resources\TicketLinkTypeResource;
@@ -209,7 +211,7 @@ class ProjectTicketMetadataSettingsController extends ApiController
return response()->json(null, 204);
}
public function storeLinkType(StoreProjectNamedSettingRequest $request, Project $project): JsonResponse
public function storeLinkType(StoreTicketLinkTypeRequest $request, Project $project): JsonResponse
{
$this->abortUnlessCanManage($request, $project);
@@ -222,7 +224,7 @@ class ProjectTicketMetadataSettingsController extends ApiController
}
public function updateLinkType(
UpdateProjectNamedSettingRequest $request,
UpdateTicketLinkTypeRequest $request,
Project $project,
TicketLinkType $ticketLinkType,
): JsonResponse {
@@ -22,10 +22,12 @@ use App\Http\Requests\DestroyProjectTicketTypeRequest;
use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreProjectTicketTypeRequest;
use App\Http\Requests\StoreTicketFieldDefinitionRequest;
use App\Http\Requests\StoreTicketLinkTypeRequest;
use App\Http\Requests\UpdateProjectNamedSettingRequest;
use App\Http\Requests\UpdateProjectTicketMetadataSettings;
use App\Http\Requests\UpdateProjectTicketTypeRequest;
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
use App\Http\Requests\UpdateTicketLinkTypeRequest;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
@@ -203,7 +205,7 @@ class ProjectTicketMetadataSettingsController extends Controller
->with('status', "{$deletedLabel} field was removed.");
}
public function storeLinkType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse
public function storeLinkType(StoreTicketLinkTypeRequest $request, Project $project): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
@@ -214,7 +216,7 @@ class ProjectTicketMetadataSettingsController extends Controller
}
public function updateLinkType(
UpdateProjectNamedSettingRequest $request,
UpdateTicketLinkTypeRequest $request,
Project $project,
TicketLinkType $ticketLinkType,
): RedirectResponse {
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreTicketLinkTypeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'counterpart_name' => ['required', 'string', 'max:255'],
'position' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTicketLinkTypeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'counterpart_name' => ['required', 'string', 'max:255'],
'position' => ['required', 'integer', 'min:1'],
];
}
}
+5 -2
View File
@@ -18,16 +18,19 @@ class TicketLinkResource extends JsonResource
{
if ($this->resource instanceof TicketLink) {
$currentTicketId = (int) ($this->resource->getAttribute('current_ticket_id') ?? 0);
$linkedTicket = $this->source_ticket_id === $currentTicketId
$isSourceTicket = $this->source_ticket_id === $currentTicketId;
$linkedTicket = $isSourceTicket
? $this->targetTicket
: $this->sourceTicket;
$ticketLinkType = TicketLinkTypeResource::make($this->ticketLinkType)->resolve();
$ticketLinkType['label'] = $this->ticketLinkType->labelForSource($isSourceTicket);
return [
'id' => $this->id,
'linked_ticket' => TicketReferenceResource::make($linkedTicket)->resolve(),
'source_ticket_id' => $this->source_ticket_id,
'target_ticket_id' => $this->target_ticket_id,
'ticket_link_type' => TicketLinkTypeResource::make($this->ticketLinkType)->resolve(),
'ticket_link_type' => $ticketLinkType,
'ticket_link_type_id' => $this->ticket_link_type_id,
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
@@ -17,6 +17,7 @@ class TicketLinkTypeResource extends JsonResource
return [
'id' => $this->id,
'label' => $this->label,
'counterpart_label' => $this->counterpart_label ?: $this->label,
'position' => $this->position,
'project_id' => $this->project_id,
'created_at' => $this->created_at?->toISOString(),
+11 -1
View File
@@ -9,12 +9,22 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['label', 'position', 'project_id'])]
#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])]
class TicketLinkType extends Model
{
/** @use HasFactory<TicketLinkTypeFactory> */
use HasFactory;
public function counterpartLabel(): string
{
return $this->counterpart_label ?: $this->label;
}
public function labelForSource(bool $isSourceTicket): string
{
return $isSourceTicket ? $this->label : $this->counterpartLabel();
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
+3 -5
View File
@@ -60,15 +60,13 @@ class TicketLinkFactory extends Factory
->first()
?? TicketLinkType::factory()->for($sourceTicket->project)->create([
'label' => 'Relates to',
'counterpart_label' => 'Relates to',
'position' => 1,
])
: TicketLinkType::query()->findOrFail($ticketLink->ticket_link_type_id);
$sortedIds = [$sourceTicket->id, $targetTicket->id];
sort($sortedIds);
$ticketLink->source_ticket_id = $sortedIds[0];
$ticketLink->target_ticket_id = $sortedIds[1];
$ticketLink->source_ticket_id = $sourceTicket->id;
$ticketLink->target_ticket_id = $targetTicket->id;
$ticketLink->ticket_link_type_id = $ticketLinkType->id;
});
}
+4 -1
View File
@@ -18,8 +18,11 @@ class TicketLinkTypeFactory extends Factory
*/
public function definition(): array
{
$label = fake()->unique()->words(2, true);
return [
'label' => fake()->unique()->words(2, true),
'label' => $label,
'counterpart_label' => $label,
'position' => 1,
'project_id' => Project::factory(),
];
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ticket_link_types', function (Blueprint $table) {
$table->string('counterpart_label')->nullable()->after('label');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ticket_link_types', function (Blueprint $table) {
$table->dropColumn('counterpart_label');
});
}
};
+2 -2
View File
@@ -20,8 +20,8 @@ class TicketLinkTypeSeeder extends Seeder
TicketLinkType::factory()
->count(2)
->sequence(
['label' => 'Relates to', 'position' => 1],
['label' => 'Blocks', 'position' => 2],
['label' => 'Relates to', 'counterpart_label' => 'Relates to', 'position' => 1],
['label' => 'Blocks', 'counterpart_label' => 'Blocked by', 'position' => 2],
)
->for($project)
->create();
@@ -243,7 +243,7 @@ const statusAllowsTarget = (
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="targetStatus.id"
:default-checked="
:checked="
statusAllowsTarget(
sourceStatus,
targetStatus,
@@ -652,17 +652,30 @@ const replacementTicketTypes = (ticketTypeId: number) =>
>
<div class="grid gap-2">
<Label for="link-type-name">
New link type name
Source label
</Label>
<Input
id="link-type-name"
name="name"
required
placeholder="Relates to"
placeholder="Blocks"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="link-type-counterpart-name">
Counterpart label
</Label>
<Input
id="link-type-counterpart-name"
name="counterpart_name"
required
placeholder="Blocked by"
/>
<InputError :message="errors.counterpart_name" />
</div>
<Button type="submit" :disabled="processing">
Add link type
</Button>
@@ -686,7 +699,7 @@ const replacementTicketTypes = (ticketTypeId: number) =>
<Label
:for="`link-type-name-${ticketLinkType.id}`"
>
Label
Source label
</Label>
<Input
:id="`link-type-name-${ticketLinkType.id}`"
@@ -697,6 +710,23 @@ const replacementTicketTypes = (ticketTypeId: number) =>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`link-type-counterpart-name-${ticketLinkType.id}`"
>
Counterpart label
</Label>
<Input
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
name="counterpart_name"
:default-value="ticketLinkType.counterpart_label"
required
/>
<InputError
:message="errors.counterpart_name"
/>
</div>
<div class="grid gap-2">
<Label
:for="`link-type-position-${ticketLinkType.id}`"
@@ -1,6 +1,7 @@
export type TicketLinkTypeModel = {
id: number;
label: string;
counterpart_label: string;
position: number;
project_id: number;
created_at: string;
+1
View File
@@ -129,6 +129,7 @@ class SettingsApiTest extends TestCase
$linkTypeId = $this->withApplicationKey($user)
->postJson(route('api.projects.settings.metadata.link-types.store', $project), [
'name' => 'Relates to',
'counterpart_name' => 'Relates to',
])
->assertCreated()
->json('data.id');
+4 -4
View File
@@ -20,9 +20,9 @@ class TicketLinkTest extends TestCase
use RefreshDatabase;
/**
* Ensure the ticket link factory normalizes the stored source and target ticket ids.
* Ensure the ticket link factory preserves the provided source and target direction.
*/
public function test_ticket_link_factory_normalizes_ticket_order(): void
public function test_ticket_link_factory_preserves_ticket_direction(): void
{
$project = Project::factory()->create([
'key' => 'CORE',
@@ -61,8 +61,8 @@ class TicketLinkTest extends TestCase
'ticket_link_type_id' => $ticketLinkType->id,
]);
$this->assertSame($firstTicket->id, $ticketLink->source_ticket_id);
$this->assertSame($secondTicket->id, $ticketLink->target_ticket_id);
$this->assertSame($secondTicket->id, $ticketLink->source_ticket_id);
$this->assertSame($firstTicket->id, $ticketLink->target_ticket_id);
}
/**
@@ -22,6 +22,7 @@ class TicketLinkTypeTest extends TestCase
$this->assertNotNull($ticketLinkType->project_id);
$this->assertIsString($ticketLinkType->label);
$this->assertSame($ticketLinkType->label, $ticketLinkType->counterpart_label);
}
/**
@@ -39,12 +40,14 @@ class TicketLinkTypeTest extends TestCase
$this->assertDatabaseHas('ticket_link_types', [
'project_id' => $coreProject->id,
'label' => 'Relates to',
'counterpart_label' => 'Relates to',
'position' => 1,
]);
$this->assertDatabaseHas('ticket_link_types', [
'project_id' => $coreProject->id,
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
'position' => 2,
]);
}
@@ -55,6 +55,7 @@ class ProjectTicketControllerTest extends TestCase
]);
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'counterpart_label' => 'Relates to',
'position' => 1,
]);
@@ -137,6 +138,7 @@ class ProjectTicketControllerTest extends TestCase
->where('ticket.worklog_entries.0.actor.name', $viewer->name)
->where('linkedTickets.0.linked_ticket.ticket_key', 'CORE-2')
->where('linkedTickets.0.ticket_link_type.label', 'Relates to')
->where('linkedTickets.0.ticket_link_type.counterpart_label', 'Relates to')
->where('repositoryBranches', [])
->where('repositoryPullRequests', [])
->where('canEditTicket', false)
@@ -144,6 +146,62 @@ class ProjectTicketControllerTest extends TestCase
);
}
/**
* Ensure linked tickets show the counterpart label from the other ticket perspective.
*/
public function test_ticket_detail_uses_counterpart_link_label_for_target_ticket(): void
{
$viewer = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$sourceTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
'title' => 'Source ticket',
]);
$targetTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
'title' => 'Target ticket',
]);
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
'position' => 1,
]);
TicketLink::factory()->create([
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $targetTicket]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->where('linkedTickets.0.linked_ticket.ticket_key', 'CORE-1')
->where('linkedTickets.0.ticket_link_type.label', 'Blocked by')
->where('linkedTickets.0.ticket_link_type.counterpart_label', 'Blocked by'));
}
/**
* Ensure project admins can still inspect the author when project author visibility is disabled.
*/
@@ -33,7 +33,8 @@ class ProjectTicketLinkControllerTest extends TestCase
->orderBy('position')
->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
'position' => 1,
]);
$ticket = Ticket::factory()->create([
@@ -59,11 +60,11 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_link_type_id' => $ticketLinkType->id,
])
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'Relates to link was saved.');
->assertSessionHas('status', 'Blocks link was saved.');
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => min($ticket->id, $linkedTicket->id),
'target_ticket_id' => max($ticket->id, $linkedTicket->id),
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
@@ -71,7 +72,14 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_id' => $ticket->id,
'actor_id' => $user->id,
'entry_type' => 'link_updated',
'summary' => 'Linked CORE-2 as Relates to.',
'summary' => 'Linked CORE-2 as Blocks.',
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $linkedTicket->id,
'actor_id' => $user->id,
'entry_type' => 'link_updated',
'summary' => 'Linked CORE-1 as Blocked by.',
]);
}
@@ -99,6 +107,7 @@ class ProjectTicketLinkControllerTest extends TestCase
->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'counterpart_label' => 'Relates to',
'position' => 1,
]);
$ticket = Ticket::factory()->create([
@@ -137,6 +146,66 @@ class ProjectTicketLinkControllerTest extends TestCase
->assertSessionHasErrors('linked_ticket_id');
}
/**
* Ensure relinking an existing reverse-direction pair updates the existing row instead of creating a duplicate.
*/
public function test_linking_an_existing_reverse_direction_pair_reuses_the_same_row(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
'position' => 1,
]);
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$linkedTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$existingLink = TicketLink::factory()->create([
'source_ticket_id' => $linkedTicket->id,
'target_ticket_id' => $ticket->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $ticket]), [
'linked_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
])
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'Blocks link was saved.');
$this->assertSame(1, TicketLink::query()->count());
$existingLink->refresh();
$this->assertSame($ticket->id, $existingLink->source_ticket_id);
$this->assertSame($linkedTicket->id, $existingLink->target_ticket_id);
}
/**
* Ensure users with edit permission can remove ticket links.
*/
@@ -155,7 +224,8 @@ class ProjectTicketLinkControllerTest extends TestCase
->orderBy('position')
->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
'position' => 1,
]);
$ticket = Ticket::factory()->create([
@@ -173,8 +243,8 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_type_id' => $taskType->id,
]);
$ticketLink = TicketLink::factory()->create([
'source_ticket_id' => min($ticket->id, $linkedTicket->id),
'target_ticket_id' => max($ticket->id, $linkedTicket->id),
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
@@ -183,7 +253,7 @@ class ProjectTicketLinkControllerTest extends TestCase
$this->actingAs($user)
->delete(route('projects.tickets.links.destroy', [$project, $ticket, $ticketLink]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'Relates to link was removed.');
->assertSessionHas('status', 'Blocks link was removed.');
$this->assertDatabaseMissing('ticket_links', [
'id' => $ticketLink->id,
@@ -193,7 +263,14 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_id' => $ticket->id,
'actor_id' => $user->id,
'entry_type' => 'link_removed',
'summary' => 'Removed Relates to link to CORE-2.',
'summary' => 'Removed Blocks link to CORE-2.',
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $linkedTicket->id,
'actor_id' => $user->id,
'entry_type' => 'link_removed',
'summary' => 'Removed Blocked by link to CORE-1.',
]);
}
}
@@ -420,6 +420,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$project = Project::factory()->create();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'counterpart_label' => 'Relates to',
'position' => 1,
]);
@@ -428,18 +429,32 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->actingAs($user)
->post(route('projects.settings.metadata.link-types.store', $project), [
'name' => 'Blocks',
'counterpart_name' => 'Blocked by',
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Blocks link type was added.');
$this->assertDatabaseHas('ticket_link_types', [
'project_id' => $project->id,
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
]);
$this->actingAs($user)
->patch(route('projects.settings.metadata.link-types.update', [$project, $ticketLinkType]), [
'name' => 'Depends on',
'counterpart_name' => 'Required by',
'position' => 2,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Depends on link type was updated.');
$this->assertDatabaseHas('ticket_link_types', [
'id' => $ticketLinkType->id,
'label' => 'Depends on',
'counterpart_label' => 'Required by',
]);
$this->actingAs($user)
->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType]))
->assertRedirect(route('projects.settings.metadata', $project))