mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-03-14 02:13:17 +00:00
🏆 Add highscore feature with API endpoints, validations, and tests
- **Added:** Endpoints for submitting highscores (`highscores.store`) and retrieving the leaderboard (`highscores.index`). - **Implemented:** Validation rules via `StoreHighscoreRequest` to ensure highscore integrity. - **Included:** `Highscore` model, migration, and factory for data handling and seeding. - **Enhanced:** Comprehensive feature tests covering submission, updating, retrieval, and payload validation.
This commit is contained in:
72
app/Http/Controllers/Api/HighscoreController.php
Normal file
72
app/Http/Controllers/Api/HighscoreController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreHighscoreRequest;
|
||||
use App\Models\Highscore;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class HighscoreController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
// npub1pt0kw36ue3w2g4haxq3wgm6a2fhtptmzsjlc2j2vphtcgle72qesgpjyc6
|
||||
$highscores = Highscore::query()
|
||||
->orderByDesc('satoshis')
|
||||
->orderBy('achieved_at')
|
||||
->get()
|
||||
->map(fn (Highscore $highscore) => [
|
||||
'npub' => $highscore->npub,
|
||||
'name' => $highscore->name,
|
||||
'satoshis' => $highscore->satoshis,
|
||||
'blocks' => $highscore->blocks,
|
||||
'datetime' => $highscore->achieved_at->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $highscores,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreHighscoreRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$achievedAt = CarbonImmutable::parse($validated['datetime']);
|
||||
|
||||
$highscore = Highscore::query()->firstOrNew([
|
||||
'npub' => $validated['npub'],
|
||||
'achieved_at' => $achievedAt,
|
||||
]);
|
||||
|
||||
$highscore->satoshis = (int) $validated['satoshis'];
|
||||
$highscore->blocks = (int) $validated['blocks'];
|
||||
|
||||
if (array_key_exists('name', $validated)) {
|
||||
$highscore->name = $validated['name'];
|
||||
}
|
||||
|
||||
$highscore->save();
|
||||
|
||||
Log::info('Highscore submission received', [
|
||||
'npub' => $highscore->npub,
|
||||
'name' => $highscore->name,
|
||||
'satoshis' => $highscore->satoshis,
|
||||
'blocks' => $highscore->blocks,
|
||||
'datetime' => $highscore->achieved_at->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Highscore received',
|
||||
'data' => [
|
||||
'npub' => $highscore->npub,
|
||||
'name' => $highscore->name,
|
||||
'satoshis' => $highscore->satoshis,
|
||||
'blocks' => $highscore->blocks,
|
||||
'datetime' => $highscore->achieved_at->toIso8601String(),
|
||||
],
|
||||
], 202);
|
||||
}
|
||||
}
|
||||
52
app/Http/Requests/StoreHighscoreRequest.php
Normal file
52
app/Http/Requests/StoreHighscoreRequest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreHighscoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function expectsJson(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'npub' => ['required', 'string', 'starts_with:npub1', 'max:100'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'satoshis' => ['required', 'integer', 'min:0'],
|
||||
'blocks' => ['required', 'integer', 'min:0'],
|
||||
'datetime' => ['required', 'date'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'npub.required' => 'An npub is required to record the highscore.',
|
||||
'npub.starts_with' => 'The npub must start with npub1.',
|
||||
'name.string' => 'The name must be a valid string.',
|
||||
'satoshis.required' => 'Please provide the earned satoshis amount.',
|
||||
'satoshis.integer' => 'Satoshis must be a whole number.',
|
||||
'blocks.required' => 'Please provide the number of blocks.',
|
||||
'blocks.integer' => 'Blocks must be a whole number.',
|
||||
'datetime.required' => 'Please provide when the score was achieved.',
|
||||
'datetime.date' => 'The datetime value must be a valid date.',
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Models/Highscore.php
Normal file
30
app/Models/Highscore.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Highscore extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* The attributes that aren't mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'satoshis' => 'integer',
|
||||
'blocks' => 'integer',
|
||||
'achieved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
27
database/factories/HighscoreFactory.php
Normal file
27
database/factories/HighscoreFactory.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Highscore>
|
||||
*/
|
||||
class HighscoreFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'npub' => 'npub1'.fake()->regexify('[a-z0-9]{20}'),
|
||||
'name' => fake()->name(),
|
||||
'satoshis' => fake()->numberBetween(0, 100000),
|
||||
'blocks' => fake()->numberBetween(0, 1000),
|
||||
'achieved_at' => fake()->dateTimeBetween('-1 year', 'now'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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::create('highscores', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('npub', 100);
|
||||
$table->string('name')->nullable();
|
||||
$table->unsignedBigInteger('satoshis');
|
||||
$table->unsignedInteger('blocks');
|
||||
$table->dateTime('achieved_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['npub', 'achieved_at']);
|
||||
$table->index('satoshis');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('highscores');
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\Api\CityController;
|
||||
use App\Http\Controllers\Api\CountryController;
|
||||
use App\Http\Controllers\Api\CourseController;
|
||||
use App\Http\Controllers\Api\HighscoreController;
|
||||
use App\Http\Controllers\Api\LecturerController;
|
||||
use App\Http\Controllers\Api\MeetupController;
|
||||
use App\Http\Controllers\Api\VenueController;
|
||||
@@ -184,3 +185,6 @@ Route::get('/lnurl-auth-callback', [\App\Http\Controllers\LnurlAuthController::c
|
||||
|
||||
Route::post('/check-auth-error', [\App\Http\Controllers\LnurlAuthController::class, 'checkError'])
|
||||
->name('auth.check-error');
|
||||
|
||||
Route::get('highscores', [HighscoreController::class, 'index'])->name('highscores.index');
|
||||
Route::post('highscores', [HighscoreController::class, 'store'])->name('highscores.store');
|
||||
|
||||
202
tests/Feature/HighscoreApiTest.php
Normal file
202
tests/Feature/HighscoreApiTest.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Highscore;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
test('highscore submission is accepted and stored', function () {
|
||||
$payload = [
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'name' => 'Player One',
|
||||
'satoshis' => 2100,
|
||||
'blocks' => 5,
|
||||
'datetime' => CarbonImmutable::now()->subMinute()->toIso8601String(),
|
||||
];
|
||||
|
||||
$response = $this->postJson(route('api.highscores.store'), $payload);
|
||||
|
||||
$response->assertAccepted()
|
||||
->assertJson([
|
||||
'message' => 'Highscore received',
|
||||
'data' => [
|
||||
'npub' => $payload['npub'],
|
||||
'name' => $payload['name'],
|
||||
'satoshis' => $payload['satoshis'],
|
||||
'blocks' => $payload['blocks'],
|
||||
'datetime' => CarbonImmutable::parse($payload['datetime'])->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('highscores', [
|
||||
'npub' => $payload['npub'],
|
||||
'name' => $payload['name'],
|
||||
'satoshis' => $payload['satoshis'],
|
||||
'blocks' => $payload['blocks'],
|
||||
'achieved_at' => CarbonImmutable::parse($payload['datetime'])->toDateTimeString(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('highscore submission updates existing attempt for same npub and datetime', function () {
|
||||
$datetime = CarbonImmutable::now()->subMinutes(10)->toIso8601String();
|
||||
|
||||
Highscore::factory()->create([
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'name' => 'Player One',
|
||||
'satoshis' => 1000,
|
||||
'blocks' => 3,
|
||||
'achieved_at' => $datetime,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('api.highscores.store'), [
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'name' => 'Player One Updated',
|
||||
'satoshis' => 2500,
|
||||
'blocks' => 7,
|
||||
'datetime' => $datetime,
|
||||
]);
|
||||
|
||||
$response->assertAccepted();
|
||||
|
||||
$this->assertDatabaseHas('highscores', [
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'name' => 'Player One Updated',
|
||||
'satoshis' => 2500,
|
||||
'blocks' => 7,
|
||||
'achieved_at' => CarbonImmutable::parse($datetime)->toDateTimeString(),
|
||||
]);
|
||||
$this->assertSame(1, Highscore::query()->count());
|
||||
});
|
||||
|
||||
test('highscore submission does not clear existing name when omitted', function () {
|
||||
$datetime = CarbonImmutable::now()->subMinutes(15);
|
||||
|
||||
Highscore::factory()->create([
|
||||
'npub' => 'npub1keepname',
|
||||
'name' => 'Keep Name',
|
||||
'satoshis' => 1234,
|
||||
'blocks' => 2,
|
||||
'achieved_at' => $datetime,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('api.highscores.store'), [
|
||||
'npub' => 'npub1keepname',
|
||||
'satoshis' => 2000,
|
||||
'blocks' => 4,
|
||||
'datetime' => $datetime->toIso8601String(),
|
||||
]);
|
||||
|
||||
$response->assertAccepted();
|
||||
|
||||
$this->assertDatabaseHas('highscores', [
|
||||
'npub' => 'npub1keepname',
|
||||
'name' => 'Keep Name',
|
||||
'satoshis' => 2000,
|
||||
'blocks' => 4,
|
||||
'achieved_at' => $datetime->toDateTimeString(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('highscores are returned sorted by satoshis', function () {
|
||||
$lowest = Highscore::factory()->create([
|
||||
'npub' => 'npub1low',
|
||||
'name' => 'Low Player',
|
||||
'satoshis' => 500,
|
||||
'blocks' => 2,
|
||||
'achieved_at' => CarbonImmutable::parse('2025-01-01T00:00:00Z'),
|
||||
]);
|
||||
$middle = Highscore::factory()->create([
|
||||
'npub' => 'npub1mid',
|
||||
'name' => 'Mid Player',
|
||||
'satoshis' => 1500,
|
||||
'blocks' => 4,
|
||||
'achieved_at' => CarbonImmutable::parse('2025-01-02T00:00:00Z'),
|
||||
]);
|
||||
$highest = Highscore::factory()->create([
|
||||
'npub' => 'npub1high',
|
||||
'name' => 'High Player',
|
||||
'satoshis' => 3000,
|
||||
'blocks' => 6,
|
||||
'achieved_at' => CarbonImmutable::parse('2025-01-03T00:00:00Z'),
|
||||
]);
|
||||
|
||||
$response = $this->getJson(route('api.highscores.index'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$response->assertJsonPath('data.0.npub', $highest->npub)
|
||||
->assertJsonPath('data.0.satoshis', $highest->satoshis)
|
||||
->assertJsonPath('data.0.name', $highest->name)
|
||||
->assertJsonPath('data.1.npub', $middle->npub)
|
||||
->assertJsonPath('data.2.npub', $lowest->npub);
|
||||
});
|
||||
|
||||
dataset('invalidHighscorePayloads', [
|
||||
'missing npub' => fn () => [
|
||||
[
|
||||
'satoshis' => 1000,
|
||||
'blocks' => 3,
|
||||
'datetime' => CarbonImmutable::now()->toIso8601String(),
|
||||
],
|
||||
['npub'],
|
||||
],
|
||||
'invalid npub prefix' => fn () => [
|
||||
[
|
||||
'npub' => 'wrong-npub',
|
||||
'satoshis' => 1000,
|
||||
'blocks' => 3,
|
||||
'datetime' => CarbonImmutable::now()->toIso8601String(),
|
||||
],
|
||||
['npub'],
|
||||
],
|
||||
'non integer satoshis' => fn () => [
|
||||
[
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'satoshis' => 'not-an-int',
|
||||
'blocks' => 3,
|
||||
'datetime' => CarbonImmutable::now()->toIso8601String(),
|
||||
],
|
||||
['satoshis'],
|
||||
],
|
||||
'negative satoshis' => fn () => [
|
||||
[
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'satoshis' => -1,
|
||||
'blocks' => 3,
|
||||
'datetime' => CarbonImmutable::now()->toIso8601String(),
|
||||
],
|
||||
['satoshis'],
|
||||
],
|
||||
'non integer blocks' => fn () => [
|
||||
[
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'satoshis' => 1000,
|
||||
'blocks' => 'not-an-int',
|
||||
'datetime' => CarbonImmutable::now()->toIso8601String(),
|
||||
],
|
||||
['blocks'],
|
||||
],
|
||||
'negative blocks' => fn () => [
|
||||
[
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'satoshis' => 1000,
|
||||
'blocks' => -1,
|
||||
'datetime' => CarbonImmutable::now()->toIso8601String(),
|
||||
],
|
||||
['blocks'],
|
||||
],
|
||||
'invalid datetime' => fn () => [
|
||||
[
|
||||
'npub' => 'npub1examplehighscorevalue',
|
||||
'satoshis' => 1000,
|
||||
'blocks' => 3,
|
||||
'datetime' => 'not a date',
|
||||
],
|
||||
['datetime'],
|
||||
],
|
||||
]);
|
||||
|
||||
it('validates highscore payload', function (array $payload, array $invalidFields) {
|
||||
$response = $this->postJson(route('api.highscores.store'), $payload);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertInvalid($invalidFields);
|
||||
})->with('invalidHighscorePayloads');
|
||||
Reference in New Issue
Block a user