work in progress: predefinedmutations
Some checks failed
linter / quality (push) Failing after 1m5s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s

This commit is contained in:
Daan Meijer 2026-06-26 10:22:55 +02:00
parent 806d17842f
commit 89a48fae16
7 changed files with 120 additions and 6 deletions

View File

@ -30,3 +30,12 @@ Welcome to the Ledgerrz codebase! This file defines the persistent guidelines, a
})
});
```
---
## 3. Advanced Browser Testing & Architectural Guidelines (Laravel Dusk)
* **Dusk Database Locks on SQLite:** Never use the `DatabaseTransactions` trait in Laravel Dusk tests if the project is backed by a SQLite database file. Since Dusk runs in concurrent, separate processes (the CLI runner and the web server), an active uncommitted database transaction in the CLI will hold a SQLite write lock. This causes the web server to fail with `SQLSTATE[HY000]: General error: 5 database is locked`. Instead, dynamically create and clean up models during test execution or use sequential DB seeds.
* **Inertia/Vue SPA Testing:** Browser tests targeting asynchronously rendered Single Page Applications must **always** use explicit `waitForText()` or `waitFor()` selectors before executing assertions. Direct assertions like `assertSee()` are immediate and will fail if the JavaScript has not finished compiling and mounting the DOM.
* **Synchronous Queue for WebSockets:** Running real-time WebSocket / Echo Dusk tests locally requires setting `QUEUE_CONNECTION=sync` and `BROADCAST_CONNECTION=reverb` in your `.env` file. This ensures broadcast jobs execute immediately on the web server rather than buffering in the database (which fails without a running background queue worker).
* **Automatic ID-to-UUID Serialization:** All models carrying a `uuid` column (such as `User`, `Dynamic`, `Ledger`, `Mutation`, and `PredefinedMutation`) must use the `App\Concerns\SerializesIdToUuid` trait. This guarantees that any raw model or relationship array/JSON conversion automatically replaces the integer `id` with the secure `uuid` under the `'id'` key.
* **UUID Controller Inputs:** When the frontend submits relationship parameters (such as `predefined_mutation_id` in requests), it only knows and passes the secure UUID. The receiving backend controller must explicitly resolve this UUID to its internal database integer ID before inserting the record (e.g. `PredefinedMutation::where('uuid', $uuid)->value('id')`), maintaining column type safety and foreign key constraints without exposing integer IDs to the client.

View File

@ -69,6 +69,7 @@ class LedgerController extends Controller
$ledger->load([
'media',
'predefinedMutations',
'mutations' => function ($query) {
$query->latest();
},

View File

@ -44,8 +44,14 @@ class MutationController extends Controller
$status = $request->user()->can('update', $ledger) ? 'approved' : 'pending';
$mutation = DB::transaction(function () use ($request, $ledger, $status) {
$predefinedId = null;
if ($request->filled('predefined_mutation_id')) {
$predefinedId = \App\Models\PredefinedMutation::where('uuid', $request->input('predefined_mutation_id'))->value('id');
}
$mutation = $ledger->mutations()->create([
...$request->except(['media', 'type', 'status']),
...$request->except(['media', 'type', 'status', 'predefined_mutation_id']),
'predefined_mutation_id' => $predefinedId,
'user_id' => $request->user()->id,
'type' => $request->input('type', $request->input('amount') >= 0 ? 'addition' : 'subtraction'),
'status' => $status,

View File

@ -29,6 +29,7 @@ class StoreMutationRequest extends FormRequest
'description' => ['required', 'string'],
'type' => ['nullable', 'string'],
'status' => ['nullable', 'string'],
'predefined_mutation_id' => ['nullable', 'exists:predefined_mutations,uuid'],
'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
];

View File

@ -2,17 +2,46 @@
import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{
dynamicId: string;
ledgerId: string;
}>();
const props = withDefaults(
defineProps<{
dynamicId: string;
ledgerId: string;
predefinedMutations?: Array<{
id: string;
name: string;
description: string | null;
amount: number;
}>;
}>(),
{
predefinedMutations: () => [],
}
);
const form = useForm({
amount: 0,
description: '',
predefined_mutation_id: null as string | null,
media: [] as File[],
});
function selectPredefinedMutation(event: Event) {
const select = event.target as HTMLSelectElement;
const selectedId = select.value;
if (!selectedId) {
form.predefined_mutation_id = null;
form.amount = 0;
form.description = '';
return;
}
const mutation = props.predefinedMutations.find(m => m.id === selectedId);
if (mutation) {
form.predefined_mutation_id = mutation.id;
form.amount = mutation.amount;
form.description = mutation.description || mutation.name;
}
}
function handleMutationFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
@ -44,6 +73,28 @@ function submit() {
<div class="c-add-mutation-form">
<h4 class="c-add-mutation-form__title">Add Mutation</h4>
<form @submit.prevent="submit" class="c-add-mutation-form__form">
<!-- Predefined Templates Selection -->
<div v-if="predefinedMutations && predefinedMutations.length > 0" class="c-add-mutation-form__field">
<label for="predefined_mutation" class="c-add-mutation-form__label"
>Apply Predefined Template</label
>
<select
id="predefined_mutation"
class="c-add-mutation-form__select"
@change="selectPredefinedMutation"
>
<option value="">-- Choose a predefined template (optional) --</option>
<option
v-for="item in predefinedMutations"
:key="item.id"
:value="item.id"
>
{{ item.name }} ({{ item.amount >= 0 ? '+' : '' }}{{ item.amount }} points)
</option>
</select>
</div>
<div class="c-add-mutation-form__field">
<label for="amount" class="c-add-mutation-form__label"
>Amount</label
@ -151,6 +202,10 @@ function submit() {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-add-mutation-form__select {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-add-mutation-form__input {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}

View File

@ -45,6 +45,12 @@ const props = defineProps<{
alignment: string;
status: string;
media?: Array<{ id: number; url: string; mime_type: string }>;
predefined_mutations?: Array<{
id: string;
name: string;
description: string | null;
amount: number;
}>;
mutations: Array<{
id: number;
user_id: number;
@ -262,7 +268,7 @@ function isOwnerUser(userId: number): boolean {
</div>
<!-- Add Mutation Form Component -->
<AddMutationForm :dynamic-id="dynamic.id" :ledger-id="ledger.id" />
<AddMutationForm :dynamic-id="dynamic.id" :ledger-id="ledger.id" :predefined-mutations="ledger.predefined_mutations" />
<!-- Mutation List Component -->
<MutationList

View File

@ -172,3 +172,39 @@ test('creating a mutation with less than -1000 points fails validation', functio
$response->assertSessionHasErrors(['amount']);
expect(Mutation::where('description', 'Abusive negative point demerit')->exists())->toBeFalse();
});
test('participant can choose and request a predefined mutation', function () {
$owner = User::factory()->create();
$participant = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id, 'score' => 100]);
$predefined = \App\Models\PredefinedMutation::create([
'ledger_id' => $ledger->id,
'name' => 'Wash the Motorbunny',
'amount' => 50,
'description' => 'Must be fully cleaned and dried.',
]);
$this->actingAs($participant);
$response = $this->post(route('dynamics.ledgers.mutations.store', [$dynamic, $ledger]), [
'amount' => 50,
'description' => 'Completed Wash the Motorbunny template chore.',
'predefined_mutation_id' => $predefined->uuid,
]);
$response->assertRedirect(route('dynamics.ledgers.show', [$dynamic, $ledger]));
$mutation = Mutation::firstWhere('description', 'Completed Wash the Motorbunny template chore.');
expect($mutation)->not->toBeNull();
expect($mutation->status)->toBe('pending');
expect($mutation->predefined_mutation_id)->toBe($predefined->id);
// Score should NOT be updated since it is pending!
$ledger->refresh();
expect($ledger->score)->toBe(100);
});