feat(scanner): refactor ticket scanning endpoint to accept UUID in request body and add validation

This commit is contained in:
2026-09-07 16:18:16 -03:00
parent e9521e65d0
commit b607c1b673
7 changed files with 74 additions and 15 deletions

View File

@@ -6086,11 +6086,16 @@
"key": "Accept", "key": "Accept",
"value": "application/json", "value": "application/json",
"type": "text" "type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
} }
], ],
"description": "Ruta Laravel: `POST /api/v1/scanner/tickets/{ticketUuid}/scan`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\TicketController@scan`\n\nRequiere autenticación Sanctum.", "description": "Ruta Laravel: `POST /api/v1/scanner/tickets/scan`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\TicketController@scan`\n\nRequiere autenticación Sanctum.",
"url": { "url": {
"raw": "{{base_url}}/api/v1/scanner/tickets/{{ticket_uuid}}/scan", "raw": "{{base_url}}/api/v1/scanner/tickets/scan",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
@@ -6099,10 +6104,18 @@
"v1", "v1",
"scanner", "scanner",
"tickets", "tickets",
"{{ticket_uuid}}",
"scan" "scan"
] ]
}, },
"body": {
"mode": "raw",
"raw": "{\n \"data\": \"{{ticket_uuid}}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": { "auth": {
"type": "bearer", "type": "bearer",
"bearer": [ "bearer": [

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Ticket\Controllers\Scanner;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Requests\ScannerTicketIndexRequest; use App\Domains\Ticket\Requests\ScannerTicketIndexRequest;
use App\Domains\Ticket\Requests\ScanTicketRequest;
use App\Domains\Ticket\Resources\Scanner\ScannedTicketResource; use App\Domains\Ticket\Resources\Scanner\ScannedTicketResource;
use App\Domains\Ticket\Resources\TicketResource; use App\Domains\Ticket\Resources\TicketResource;
use App\Domains\Ticket\Services\ScannerTicketService; use App\Domains\Ticket\Services\ScannerTicketService;
@@ -35,13 +36,13 @@ class TicketController extends Controller
); );
} }
public function scan(Request $request, string $ticketUuid): TicketResource public function scan(ScanTicketRequest $request): TicketResource
{ {
/** @var User $scanner */ /** @var User $scanner */
$scanner = $request->user(); $scanner = $request->user();
return TicketResource::make( return TicketResource::make(
$this->ticketService->scan($scanner, $ticketUuid) $this->ticketService->scan($scanner, $request->validated('data'))
); );
} }
} }

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ScanTicketRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'data' => ['required', 'uuid'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'data.required' => 'El QR proporcionado es inválido.',
'data.uuid' => 'El QR proporcionado es inválido.',
];
}
}

View File

@@ -7,8 +7,7 @@ Route::prefix('v1/scanner/tickets')
->middleware(['auth:sanctum', 'scanner.tenant']) ->middleware(['auth:sanctum', 'scanner.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('/', [TicketController::class, 'index']); Route::get('/', [TicketController::class, 'index']);
Route::post('scan', [TicketController::class, 'scan']);
Route::get('{ticketUuid}', [TicketController::class, 'show']) Route::get('{ticketUuid}', [TicketController::class, 'show'])
->whereUuid('ticketUuid'); ->whereUuid('ticketUuid');
Route::post('{ticketUuid}/scan', [TicketController::class, 'scan'])
->whereUuid('ticketUuid');
}); });

View File

@@ -113,6 +113,7 @@ function bodyFor(string $method, string $uri): ?array
'POST api/v1/scanner/password/reset-attempts' => ['email' => '{{scanner_email}}'], 'POST api/v1/scanner/password/reset-attempts' => ['email' => '{{scanner_email}}'],
'POST api/v1/scanner/password/reset-attempts/validate' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}'], 'POST api/v1/scanner/password/reset-attempts/validate' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}'],
'POST api/v1/scanner/password/reset' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{scanner_password}}', 'password_confirmation' => '{{scanner_password}}'], 'POST api/v1/scanner/password/reset' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{scanner_password}}', 'password_confirmation' => '{{scanner_password}}'],
'POST api/v1/scanner/tickets/scan' => ['data' => '{{ticket_uuid}}'],
]; ];
if (isset($exact[$key])) { if (isset($exact[$key])) {

View File

@@ -68,7 +68,7 @@ class PrepareLoadTestTicketsCommandTest extends TestCase
$first = $rows[0]; $first = $rows[0];
$this->withToken($first['scanner_token']) $this->withToken($first['scanner_token'])
->postJson("/api/v1/scanner/tickets/{$first['ticket_uuid']}/scan") ->postJson('/api/v1/scanner/tickets/scan', ['data' => $first['ticket_uuid']])
->assertOk() ->assertOk()
->assertJsonPath('data.ticket', $first['ticket_uuid']); ->assertJsonPath('data.ticket', $first['ticket_uuid']);

View File

@@ -203,7 +203,7 @@ class ScannerTicketControllerTest extends TestCase
$ticket = $this->createTicket('77777777-7777-4777-8777-777777777777'); $ticket = $this->createTicket('77777777-7777-4777-8777-777777777777');
Sanctum::actingAs($this->scanner); Sanctum::actingAs($this->scanner);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])
->assertOk() ->assertOk()
->assertJsonPath('data.ticket', $ticket->ticket) ->assertJsonPath('data.ticket', $ticket->ticket)
->assertJsonPath('data.scanner_user_id', $this->scanner->id) ->assertJsonPath('data.scanner_user_id', $this->scanner->id)
@@ -217,11 +217,26 @@ class ScannerTicketControllerTest extends TestCase
$this->assertNotNull($ticket->fresh()->used_at); $this->assertNotNull($ticket->fresh()->used_at);
} }
public function test_scan_requires_a_uuid_in_the_data_field(): void
{
Sanctum::actingAs($this->scanner);
$this->postJson('/api/v1/scanner/tickets/scan')
->assertUnprocessable()
->assertJsonPath('message', 'El QR proporcionado es inválido.')
->assertJsonPath('errors.data.0', 'El QR proporcionado es inválido.');
$this->postJson('/api/v1/scanner/tickets/scan', ['data' => 'not-a-uuid'])
->assertUnprocessable()
->assertJsonPath('message', 'El QR proporcionado es inválido.')
->assertJsonPath('errors.data.0', 'El QR proporcionado es inválido.');
}
public function test_ticket_cannot_be_scanned_twice(): void public function test_ticket_cannot_be_scanned_twice(): void
{ {
$ticket = $this->createTicket('88888888-8888-4888-8888-888888888888'); $ticket = $this->createTicket('88888888-8888-4888-8888-888888888888');
Sanctum::actingAs($this->scanner); Sanctum::actingAs($this->scanner);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertOk(); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])->assertOk();
$otherScanner = User::factory()->create([ $otherScanner = User::factory()->create([
'rol_codigo' => RoleCode::Scanner->value, 'rol_codigo' => RoleCode::Scanner->value,
@@ -230,7 +245,7 @@ class ScannerTicketControllerTest extends TestCase
$otherScanner->scanCategories()->attach($this->category); $otherScanner->scanCategories()->attach($this->category);
Sanctum::actingAs($otherScanner); Sanctum::actingAs($otherScanner);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])
->assertUnprocessable() ->assertUnprocessable()
->assertJsonValidationErrors('ticket'); ->assertJsonValidationErrors('ticket');
$this->assertSame($this->scanner->id, $ticket->fresh()->scanner_user_id); $this->assertSame($this->scanner->id, $ticket->fresh()->scanner_user_id);
@@ -249,7 +264,7 @@ class ScannerTicketControllerTest extends TestCase
); );
Sanctum::actingAs($this->scanner); Sanctum::actingAs($this->scanner);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])
->assertUnprocessable() ->assertUnprocessable()
->assertJsonValidationErrors('ticket'); ->assertJsonValidationErrors('ticket');
$this->assertNull($ticket->fresh()->used_at); $this->assertNull($ticket->fresh()->used_at);
@@ -274,7 +289,7 @@ class ScannerTicketControllerTest extends TestCase
->assertOk() ->assertOk()
->assertJsonPath('data.id', $ticket->id); ->assertJsonPath('data.id', $ticket->id);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])
->assertOk() ->assertOk()
->assertJsonPath('data.scanner_user_id', $this->scanner->id); ->assertJsonPath('data.scanner_user_id', $this->scanner->id);
} }
@@ -297,7 +312,7 @@ class ScannerTicketControllerTest extends TestCase
$this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}") $this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}")
->assertNotFound(); ->assertNotFound();
$this->postJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}/scan") $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $foreignTicket->ticket])
->assertNotFound(); ->assertNotFound();
} }
@@ -313,7 +328,7 @@ class ScannerTicketControllerTest extends TestCase
$this->getJson('/api/v1/scanner/tickets')->assertForbidden(); $this->getJson('/api/v1/scanner/tickets')->assertForbidden();
$this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")->assertForbidden(); $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")->assertForbidden();
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertForbidden(); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])->assertForbidden();
$this->assertNull($ticket->fresh()->used_at); $this->assertNull($ticket->fresh()->used_at);
} }