diff --git a/app/Http/Controllers/Api/HighscoreController.php b/app/Http/Controllers/Api/HighscoreController.php new file mode 100644 index 0000000..c712342 --- /dev/null +++ b/app/Http/Controllers/Api/HighscoreController.php @@ -0,0 +1,72 @@ +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); + } +} diff --git a/app/Http/Requests/StoreHighscoreRequest.php b/app/Http/Requests/StoreHighscoreRequest.php new file mode 100644 index 0000000..cef6520 --- /dev/null +++ b/app/Http/Requests/StoreHighscoreRequest.php @@ -0,0 +1,52 @@ +|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.', + ]; + } +} diff --git a/app/Models/Highscore.php b/app/Models/Highscore.php new file mode 100644 index 0000000..19b6cfb --- /dev/null +++ b/app/Models/Highscore.php @@ -0,0 +1,30 @@ + 'integer', + 'satoshis' => 'integer', + 'blocks' => 'integer', + 'achieved_at' => 'datetime', + ]; +} diff --git a/database/factories/HighscoreFactory.php b/database/factories/HighscoreFactory.php new file mode 100644 index 0000000..ca7b28b --- /dev/null +++ b/database/factories/HighscoreFactory.php @@ -0,0 +1,27 @@ + + */ +class HighscoreFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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'), + ]; + } +} diff --git a/database/migrations/2026_02_02_105837_create_highscores_table.php b/database/migrations/2026_02_02_105837_create_highscores_table.php new file mode 100644 index 0000000..ab8ddf7 --- /dev/null +++ b/database/migrations/2026_02_02_105837_create_highscores_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/routes/api.php b/routes/api.php index c6dd9d1..3963db9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/HighscoreApiTest.php b/tests/Feature/HighscoreApiTest.php new file mode 100644 index 0000000..a7c9431 --- /dev/null +++ b/tests/Feature/HighscoreApiTest.php @@ -0,0 +1,202 @@ + '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');