create(); $assignee = User::factory()->create([ 'name' => 'Taylor Reed', ]); $project = Project::factory()->create([ 'key' => 'AUTH', ]); app(InitializeProjectTicketMetadata::class)->handle($project); BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $priority = $project->ticketPriorities()->firstOrFail(); $backlogStatus = $project->ticketStatuses() ->whereNull('board_column_id') ->firstOrFail(); $ticket = Ticket::factory()->create([ 'assignee_user_id' => $assignee->id, 'project_id' => $project->id, 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_key' => 'AUTH-1', 'sequence_number' => 1, 'title' => 'Seed backlog item', ]); $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->get(route('projects.backlog.index', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Backlog') ->where('backlogTicketStatuses.0.name', 'Backlog') ->where('canEditTickets', false) ->where('project.id', $project->id) ->where('tickets.0.ticket_key', $ticket->ticket_key) ->where('tickets.0.assignee.id', $assignee->id) ->where('tickets.0.assignee.name', $assignee->name) ->where('tickets.0.ticket_priority.name', $priority->name) ->where('tickets.0.ticket_status.name', $backlogStatus->name) ->where('canCreateTickets', false), ); } /** * Ensure the backlog payload supports nested tickets and their allowed status moves. */ public function test_backlog_includes_nested_tickets_and_allowed_status_moves(): void { $editor = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'TREE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $backlogStatus = $project->ticketStatuses() ->whereNull('board_column_id') ->firstOrFail(); $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $boardStatus = TicketStatus::factory() ->for($project) ->for($boardColumn) ->create([ 'name' => 'Ready', 'position' => 1, ]); $backlogStatus->allowedTargetStatuses()->sync([$boardStatus->id]); $parentType = $project->ticketTypes() ->where('is_parent', true) ->firstOrFail(); $taskType = $project->ticketTypes() ->where('is_parent', false) ->firstOrFail(); $priority = $project->ticketPriorities()->firstOrFail(); $parentTicket = Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'TREE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, 'ticket_type_id' => $parentType->id, 'title' => 'Parent ticket', ]); $childTicket = Ticket::factory()->create([ 'parent_ticket_id' => $parentTicket->id, 'project_id' => $project->id, 'sequence_number' => 2, 'ticket_key' => 'TREE-2', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, 'ticket_type_id' => $taskType->id, 'title' => 'Child ticket', ]); $this->syncProjectPermissions($project, $editor, ['edit', 'view']); $this->actingAs($editor) ->get(route('projects.backlog.index', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Backlog') ->where('canEditTickets', true) ->has('tickets', 2) ->where('tickets.0.id', $childTicket->id) ->where('tickets.0.parent_ticket_id', $parentTicket->id) ->where('tickets.0.ticket_status.allowed_target_statuses.0.id', $boardStatus->id) ->where('tickets.1.id', $parentTicket->id) ->where('tickets.1.parent_ticket_id', null) ->where('tickets.1.ticket_status.allowed_target_statuses.0.id', $boardStatus->id), ); $this->assertDatabaseHas('tickets', [ 'id' => $parentTicket->id, 'ticket_status_id' => $backlogStatus->id, ]); $this->assertDatabaseHas('tickets', [ 'id' => $childTicket->id, 'parent_ticket_id' => $parentTicket->id, 'ticket_status_id' => $backlogStatus->id, ]); } /** * Ensure projects without backlog tickets receive an empty collection. */ public function test_projects_without_backlog_tickets_receive_an_empty_collection(): void { $viewer = User::factory()->create(); $project = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); $this->syncProjectPermissions($project, $viewer, ['view']); $this->actingAs($viewer) ->get(route('projects.backlog.index', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Backlog') ->where('tickets', []), ); } /** * Ensure creating tickets requires the project write permission. */ public function test_creating_tickets_requires_project_write_permission(): void { $user = User::factory()->create(); $project = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); $priority = $project->ticketPriorities()->firstOrFail(); $backlogStatus = $project->ticketStatuses() ->whereNull('board_column_id') ->firstOrFail(); $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->post(route('projects.tickets.store', $project), [ 'title' => 'New ticket', 'description' => 'Description', 'acceptance_criteria' => [[ 'checked' => false, 'indent' => 0, 'text' => 'Criteria', ]], 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, ]) ->assertForbidden(); } /** * Ensure project-local keys increment and ticket creation accepts duplicate titles. */ public function test_users_with_write_permission_can_add_duplicate_titled_tickets(): void { $user = User::factory()->create(); $assignee = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'AUTH', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); $backlogStatus = $project->ticketStatuses() ->whereNull('board_column_id') ->firstOrFail(); $this->syncProjectPermissions($project, $user, ['create', 'view']); $payload = [ 'title' => 'Same title', 'description' => 'Description', 'acceptance_criteria' => [[ 'checked' => false, 'indent' => 0, 'text' => 'Criteria', ]], 'assignee_user_id' => $assignee->id, 'story_points' => 3, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, ]; $this->actingAs($user) ->post(route('projects.tickets.store', $project), $payload) ->assertRedirect(route('projects.tickets.show', [$project, 1])) ->assertSessionHas('status', 'AUTH-1 was created.'); $this->actingAs($user) ->post(route('projects.tickets.store', $project), $payload) ->assertRedirect(route('projects.tickets.show', [$project, 2])) ->assertSessionHas('status', 'AUTH-2 was created.'); $this->assertDatabaseHas('tickets', [ 'project_id' => $project->id, 'ticket_key' => 'AUTH-1', 'title' => 'Same title', 'assignee_user_id' => $assignee->id, 'author_user_id' => $user->id, 'story_points' => 3, 'ticket_status_id' => $backlogStatus->id, ]); $this->assertDatabaseHas('tickets', [ 'project_id' => $project->id, 'ticket_key' => 'AUTH-2', 'title' => 'Same title', 'assignee_user_id' => $assignee->id, 'author_user_id' => $user->id, 'story_points' => 3, 'ticket_status_id' => $backlogStatus->id, ]); $this->assertDatabaseCount('ticket_worklog_entries', 2); $this->assertDatabaseHas('ticket_worklog_entries', [ 'entry_type' => 'created', 'summary' => 'Ticket was created in Backlog.', ]); } /** * Ensure ticket creation can target board column statuses. */ public function test_creating_tickets_accepts_board_column_statuses(): void { $user = User::factory()->create(); $project = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $boardStatus = TicketStatus::factory() ->for($project) ->for($boardColumn) ->create([ 'name' => 'Todo', 'position' => 1, ]); $this->syncProjectPermissions($project, $user, ['create', 'view']); $this->actingAs($user) ->from(route('projects.backlog.index', $project)) ->post(route('projects.tickets.store', $project), [ 'title' => 'New ticket', 'description' => 'Description', 'acceptance_criteria' => [[ 'checked' => false, 'indent' => 0, 'text' => 'Criteria', ]], 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, 'ticket_status_id' => $boardStatus->id, ]) ->assertRedirect(route('projects.tickets.show', [$project, 1])) ->assertSessionHas('status', "{$project->key}-1 was created."); $this->assertDatabaseHas('tickets', [ 'project_id' => $project->id, 'ticket_status_id' => $boardStatus->id, 'title' => 'New ticket', ]); } }