Author SHA1 Message Date
Daan Meijer d751cd4fdd Merge branch 'feature/defensive-mutation-constraints'
linter / quality (push) Failing after 12m11s
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Failing after 1m7s
tests / ci (8.5) (push) Failing after 1m6s
2026-06-23 11:55:03 +02:00
Daan Meijer 807a260cf6 Merge branch 'feature/deconstruct-chat' 2026-06-23 11:54:45 +02:00
Daan Meijer a7159c4527 Merge branch 'feature/model-broadcasts' 2026-06-23 11:44:23 +02:00
Daan Meijer cca5952470 feat: Implement defensive point constraints and validation tests 2026-06-23 11:41:20 +02:00
Daan Meijer af1eabfa01 refactor: Relocate real-time mutation broadcasts to model hooks 2026-06-23 11:38:06 +02:00
Daan Meijer 682da3dea8 feat: Implement morph maps and historical migration 2026-06-23 11:34:07 +02:00
8 changed files with 152 additions and 9 deletions
@@ -71,9 +71,6 @@ class MutationController extends Controller
return $mutation; return $mutation;
}); });
// Broadcast the real-time creation event!
broadcast(new MutationCreated($mutation));
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]); return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
@@ -149,9 +146,6 @@ class MutationController extends Controller
} }
broadcast(new MessageSent($dynamicMsg)); broadcast(new MessageSent($dynamicMsg));
// Broadcast the real-time update event!
broadcast(new MutationUpdated($mutation));
return redirect()->back(); return redirect()->back();
} }
@@ -37,7 +37,7 @@ class PredefinedMutationController extends Controller
$request->validate([ $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'amount' => ['required', 'integer'], 'amount' => ['required', 'integer', 'not_in:0', 'min:-1000', 'max:1000'],
]); ]);
$ledger->predefinedMutations()->create($request->all()); $ledger->predefinedMutations()->create($request->all());
@@ -69,7 +69,7 @@ class PredefinedMutationController extends Controller
$request->validate([ $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'amount' => ['required', 'integer'], 'amount' => ['required', 'integer', 'not_in:0', 'min:-1000', 'max:1000'],
]); ]);
$predefinedMutation->update($request->all()); $predefinedMutation->update($request->all());
+1 -1
View File
@@ -25,7 +25,7 @@ class StoreMutationRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'amount' => ['required', 'integer'], 'amount' => ['required', 'integer', 'not_in:0', 'min:-1000', 'max:1000'],
'description' => ['required', 'string'], 'description' => ['required', 'string'],
'type' => ['nullable', 'string'], 'type' => ['nullable', 'string'],
'status' => ['nullable', 'string'], 'status' => ['nullable', 'string'],
+9
View File
@@ -92,6 +92,15 @@ class Mutation extends Model
]); ]);
} }
broadcast(new MessageSent($dynamicMsg)); broadcast(new MessageSent($dynamicMsg));
// Trigger the real-time creation broadcast dynamically
broadcast(new \App\Events\MutationCreated($mutation));
});
static::updated(function (Mutation $mutation) {
if ($mutation->wasChanged('status')) {
broadcast(new \App\Events\MutationUpdated($mutation));
}
}); });
} }
+8
View File
@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -26,6 +27,13 @@ class AppServiceProvider extends ServiceProvider
{ {
JsonResource::withoutWrapping(); JsonResource::withoutWrapping();
$this->configureDefaults(); $this->configureDefaults();
Relation::morphMap([
'user' => \App\Models\User::class,
'dynamic' => \App\Models\Dynamic::class,
'ledger' => \App\Models\Ledger::class,
'mutation' => \App\Models\Mutation::class,
]);
} }
/** /**
@@ -0,0 +1,30 @@
<?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::table('messages', function (Blueprint $table) {
DB::table('messages')->where('subject_type', 'App\\Models\\User')->update(['subject_type' => 'user']);
DB::table('messages')->where('subject_type', 'App\\Models\\Mutation')->update(['subject_type' => 'mutation']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
DB::table('messages')->where('subject_type', 'user')->update(['subject_type' => 'App\\Models\\User']);
DB::table('messages')->where('subject_type', 'mutation')->update(['subject_type' => 'App\\Models\\Mutation']);
});
}
};
+51
View File
@@ -121,3 +121,54 @@ test('owner can approve a pending suggestion and it is updated and logged', func
expect($dynamicChatMessages->last()->user_id)->toBeNull(); expect($dynamicChatMessages->last()->user_id)->toBeNull();
expect($dynamicChatMessages->last()->content)->toBe("<user:{$owner->id}> APPROVED the suggestion \"Polished dungeon floors\" for +20 points on \"{$ledger->name}\" ledger."); expect($dynamicChatMessages->last()->content)->toBe("<user:{$owner->id}> APPROVED the suggestion \"Polished dungeon floors\" for +20 points on \"{$ledger->name}\" ledger.");
}); });
test('creating a mutation with 0 points fails validation', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($owner);
$response = $this->post(route('dynamics.ledgers.mutations.store', [$dynamic, $ledger]), [
'amount' => 0,
'description' => 'Zero point spam',
]);
$response->assertSessionHasErrors(['amount']);
expect(Mutation::where('description', 'Zero point spam')->exists())->toBeFalse();
});
test('creating a mutation with more than 1000 points fails validation', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($owner);
$response = $this->post(route('dynamics.ledgers.mutations.store', [$dynamic, $ledger]), [
'amount' => 1001,
'description' => 'Abusive positive point reward',
]);
$response->assertSessionHasErrors(['amount']);
expect(Mutation::where('description', 'Abusive positive point reward')->exists())->toBeFalse();
});
test('creating a mutation with less than -1000 points fails validation', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($owner);
$response = $this->post(route('dynamics.ledgers.mutations.store', [$dynamic, $ledger]), [
'amount' => -1001,
'description' => 'Abusive negative point demerit',
]);
$response->assertSessionHasErrors(['amount']);
expect(Mutation::where('description', 'Abusive negative point demerit')->exists())->toBeFalse();
});
+51
View File
@@ -164,3 +164,54 @@ test('owner can delete predefined mutation', function () {
'id' => $predefined->id, 'id' => $predefined->id,
]); ]);
}); });
test('creating a predefined mutation with 0 points fails validation', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($owner);
$response = $this->post(route('dynamics.ledgers.predefined-mutations.store', [$dynamic->uuid, $ledger->uuid]), [
'name' => 'Zero point predefined',
'amount' => 0,
]);
$response->assertSessionHasErrors(['amount']);
expect(PredefinedMutation::where('name', 'Zero point predefined')->exists())->toBeFalse();
});
test('creating a predefined mutation with more than 1000 points fails validation', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($owner);
$response = $this->post(route('dynamics.ledgers.predefined-mutations.store', [$dynamic->uuid, $ledger->uuid]), [
'name' => 'Abusive positive predefined',
'amount' => 1001,
]);
$response->assertSessionHasErrors(['amount']);
expect(PredefinedMutation::where('name', 'Abusive positive predefined')->exists())->toBeFalse();
});
test('creating a predefined mutation with less than -1000 points fails validation', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($owner);
$response = $this->post(route('dynamics.ledgers.predefined-mutations.store', [$dynamic->uuid, $ledger->uuid]), [
'name' => 'Abusive negative predefined',
'amount' => -1001,
]);
$response->assertSessionHasErrors(['amount']);
expect(PredefinedMutation::where('name', 'Abusive negative predefined')->exists())->toBeFalse();
});