Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions app/Http/Controllers/TicketController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

namespace App\Http\Controllers;

use App\Http\Requests\TicketIndexRequest;
use App\Http\Requests\TicketTransferRequest;
use App\Models\Setting;
use App\Models\Ticket;
use Illuminate\Http\Request;

class TicketController extends Controller
{
public function index(Request $request)
public function index(TicketIndexRequest $request)
{
$order = $request->input('order', 'event');
$direction = $request->input('order_direction', $order === 'event' ? 'desc' : 'asc');

$query = $request
->user()
->tickets();
Expand All @@ -21,13 +25,40 @@ public function index(Request $request)
});
}

$tickets = $query->with(['event' => function ($query) {
$query->orderBy('starts_at', 'DESC');
}, 'type', 'seat'])
->paginate();
switch ($order) {
case 'reference':
$query->orderBy('reference', $direction);
break;
case 'type':
$query->withAggregate('type', 'name')
->orderBy('type_name', $direction);
break;
case 'seat':
$query->withAggregate('seat', 'row')
->withAggregate('seat', 'number')
->orderBy('seat_row', $direction)
->orderBy('seat_number', $direction);
break;
case 'event':
default:
$query->withAggregate('event', 'starts_at')
Comment thread
mintopia marked this conversation as resolved.
->orderBy('event_starts_at', $direction);
break;
}

$params = [
'order' => $order,
'order_direction' => $direction,
];

$tickets = $query->with(['event', 'type', 'seat'])
->orderBy('id')
->paginate()
->appends($params);

return view('tickets.index', [
'tickets' => $tickets,
'params' => $params,
]);
}

Expand Down
38 changes: 38 additions & 0 deletions app/Http/Requests/TicketIndexRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace App\Http\Requests;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class TicketIndexRequest extends FormRequest
{
/**
* Columns the tickets index may be sorted by.
*
* @var array<int, string>
*/
public const SORTABLE = ['reference', 'type', 'seat', 'event'];

/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'order' => ['nullable', Rule::in(self::SORTABLE)],
'order_direction' => ['nullable', Rule::in(['asc', 'desc'])],
];
}
}
8 changes: 4 additions & 4 deletions resources/views/tickets/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Reference</th>
<th>Type</th>
<th>Seat</th>
<th>Event</th>
<th>@include('partials._sortheader', ['title' => 'Reference', 'field' => 'reference'])</th>
<th>@include('partials._sortheader', ['title' => 'Type', 'field' => 'type'])</th>
<th>@include('partials._sortheader', ['title' => 'Seat', 'field' => 'seat'])</th>
<th>@include('partials._sortheader', ['title' => 'Event', 'field' => 'event', 'direction' => 'desc'])</th>
<th class="w-1"></th>
</tr>
</thead>
Expand Down
64 changes: 64 additions & 0 deletions tests/Feature/TicketSortingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Tests\Feature;

use App\Models\Event;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class TicketSortingTest extends TestCase
{
use RefreshDatabase;

public function testTicketsPageRendersSortableReferenceHeaderLink()
{
$user = User::factory()->create(['first_login' => false]);
$event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]);
Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']);

$response = $this->actingAs($user)->get(route('tickets.index'));

// assertOk should hold even BEFORE the view change (plain-text headers still render 200).
// If this fails before you touch the view, it is an environment/layout problem to resolve first,
// not part of this feature.
$response->assertOk();

// This is the real red -> green assertion for this task: the Reference header must link with order=reference.
// On the default request the pagination links use order=event, so this string only appears once the
// Reference sort header exists.
$response->assertSee('order=reference', false);
}

public function testTicketsPageOrdersRowsByReferenceAscending()
{
$user = User::factory()->create(['first_login' => false]);
$event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]);
Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']);
Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'ZZZ-999']);

$response = $this->actingAs($user)->get(route('tickets.index', ['order' => 'reference', 'order_direction' => 'asc']));

$response->assertOk();
$response->assertSeeInOrder(['AAA-001', 'ZZZ-999']);
}

public function testTicketsPageRejectsUnknownOrderColumn()
{
$user = User::factory()->create(['first_login' => false]);

$response = $this->actingAs($user)->get(route('tickets.index', ['order' => 'bogus']));

$response->assertSessionHasErrors('order');
}

public function testTicketsPageRejectsUnknownOrderDirection()
{
$user = User::factory()->create(['first_login' => false]);

$response = $this->actingAs($user)->get(route('tickets.index', ['order_direction' => 'sideways']));

$response->assertSessionHasErrors('order_direction');
}
}
140 changes: 132 additions & 8 deletions tests/Unit/app/Http/Controllers/TicketControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
namespace Tests\Unit\app\Http\Controllers;

use App\Http\Controllers\TicketController;
use App\Http\Requests\TicketIndexRequest;
use App\Http\Requests\TicketTransferRequest;
use App\Models\Event;
use App\Models\Seat;
use App\Models\Setting;
use App\Models\Ticket;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
Expand All @@ -32,14 +35,7 @@ public function testIndexFiltersDraftEventsForNonAdmin()
$ticketForDraft = Ticket::factory()->create(['event_id' => Event::factory()->create(['draft' => true, 'starts_at' => now(), 'ends_at' => now()->addHour()])->id, 'user_id' => $user->id]);
$ticketForLive = Ticket::factory()->create(['event_id' => Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()])->id, 'user_id' => $user->id]);

$this->actingAs($user);
$controller = new TicketController();
$request = Request::create('/', 'GET');
$request->setUserResolver(function () use ($user) {
return $user;
});

$response = $controller->index($request);
$response = $this->indexResponse($user);
$this->assertInstanceOf(View::class, $response);
$data = $response->getData();
$tickets = $data['tickets'];
Expand Down Expand Up @@ -136,4 +132,132 @@ public function testTransferWhenTransfersDisabledReturnsError()
$this->assertStringContainsString('/tickets', $response->getTargetUrl());
$this->assertNotEmpty($response->getSession()->get('errorMessage'));
}

public function testIndexDefaultsToNewestEventFirst()
{
$user = User::factory()->create();
$oldEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->subDays(5), 'ends_at' => now()->subDays(5)->addHour()]);
$newEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->addDays(5), 'ends_at' => now()->addDays(5)->addHour()]);
$oldTicket = Ticket::factory()->create(['event_id' => $oldEvent->id, 'user_id' => $user->id]);
$newTicket = Ticket::factory()->create(['event_id' => $newEvent->id, 'user_id' => $user->id]);

$tickets = $this->indexResponse($user)->getData()['tickets'];

$this->assertSame([$newTicket->id, $oldTicket->id], $tickets->pluck('id')->all());
}

public function testIndexPassesResolvedSortParamsToView()
{
$user = User::factory()->create();

$data = $this->indexResponse($user)->getData();

$this->assertSame(['order' => 'event', 'order_direction' => 'desc'], $data['params']);
}

public function testIndexSortsByReference()
{
$user = User::factory()->create();
$event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]);
$a = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']);
$z = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'ZZZ-999']);

$asc = $this->indexResponse($user, ['order' => 'reference', 'order_direction' => 'asc'])->getData()['tickets'];
$this->assertSame([$a->id, $z->id], $asc->pluck('id')->all());

$desc = $this->indexResponse($user, ['order' => 'reference', 'order_direction' => 'desc'])->getData()['tickets'];
$this->assertSame([$z->id, $a->id], $desc->pluck('id')->all());
}

public function testIndexSortsByType()
{
$user = User::factory()->create();
$event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]);
$typeA = TicketType::factory()->create(['name' => 'AAA Type']);
$typeZ = TicketType::factory()->create(['name' => 'ZZZ Type']);
$ticketA = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'ticket_type_id' => $typeA->id]);
$ticketZ = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'ticket_type_id' => $typeZ->id]);

$asc = $this->indexResponse($user, ['order' => 'type', 'order_direction' => 'asc'])->getData()['tickets'];
$this->assertSame([$ticketA->id, $ticketZ->id], $asc->pluck('id')->all());

$desc = $this->indexResponse($user, ['order' => 'type', 'order_direction' => 'desc'])->getData()['tickets'];
$this->assertSame([$ticketZ->id, $ticketA->id], $desc->pluck('id')->all());
}

public function testIndexSortsBySeat()
{
$user = User::factory()->create();
$event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]);
$front = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]);
$back = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]);
Seat::factory()->create(['ticket_id' => $front->id, 'row' => 1, 'number' => 1]);
Seat::factory()->create(['ticket_id' => $back->id, 'row' => 9, 'number' => 9]);

$asc = $this->indexResponse($user, ['order' => 'seat', 'order_direction' => 'asc'])->getData()['tickets'];
$this->assertSame([$front->id, $back->id], $asc->pluck('id')->all());

$desc = $this->indexResponse($user, ['order' => 'seat', 'order_direction' => 'desc'])->getData()['tickets'];
$this->assertSame([$back->id, $front->id], $desc->pluck('id')->all());
}

public function testIndexSeatSortKeepsUnseatedTickets()
{
$user = User::factory()->create();
$event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]);

$frontRow = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]);
$backRow = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]);
$unseated = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]);

Seat::factory()->create(['ticket_id' => $frontRow->id, 'row' => 1, 'number' => 1, 'label' => '1-1']);
Seat::factory()->create(['ticket_id' => $backRow->id, 'row' => 2, 'number' => 5, 'label' => '2-5']);

$tickets = $this->indexResponse($user, ['order' => 'seat', 'order_direction' => 'asc'])->getData()['tickets'];
$ids = $tickets->pluck('id')->all();

$this->assertCount(3, $tickets);
$this->assertContains($unseated->id, $ids);

$frontPosition = array_search($frontRow->id, $ids);
$backPosition = array_search($backRow->id, $ids);
$this->assertLessThan($backPosition, $frontPosition);
}

public function testIndexSortsByEventStartsAt()
{
$user = User::factory()->create();
$oldEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->subDays(5), 'ends_at' => now()->subDays(5)->addHour()]);
$newEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->addDays(5), 'ends_at' => now()->addDays(5)->addHour()]);
$oldTicket = Ticket::factory()->create(['event_id' => $oldEvent->id, 'user_id' => $user->id]);
$newTicket = Ticket::factory()->create(['event_id' => $newEvent->id, 'user_id' => $user->id]);

$asc = $this->indexResponse($user, ['order' => 'event', 'order_direction' => 'asc'])->getData()['tickets'];
$this->assertSame([$oldTicket->id, $newTicket->id], $asc->pluck('id')->all());
}

public function testIndexPaginationPreservesSort()
{
$user = User::factory()->create();
$event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]);
Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']);

$tickets = $this->indexResponse($user, ['order' => 'reference', 'order_direction' => 'asc'])->getData()['tickets'];
$url = $tickets->url(1);

$this->assertStringContainsString('order=reference', $url);
$this->assertStringContainsString('order_direction=asc', $url);
}

private function indexResponse(User $user, array $query = []): View
{
$this->actingAs($user);

$request = TicketIndexRequest::create('/', 'GET', $query);
$request->setUserResolver(function () use ($user) {
return $user;
});

return (new TicketController())->index($request);
}
}