PLATFORM
  • Tails

    Create websites with TailwindCSS

  • Blocks

    Design blocks for your website

  • Wave

    Start building the next great SAAS

  • Pines

    Alpine & Tailwind UI Library

  • Auth

    Plug'n Play Authentication for Laravel

  • Designer comingsoon

    Create website designs with AI

  • DevBlog comingsoon

    Blog platform for developers

  • Static

    Build a simple static website

  • SaaS Adventure

    21-day program to build a SAAS

Question By
Unsolved

Test for storing character fails with table empty

ookma-kyi

May 8th, 2024 11:11 AM

I am trying to test my store route and when the test to store a character runs it fails with 'table empty'.

Here is the test itself:

it('Stores a character', function () {
    // Arrange
    $user = User::factory()->create();

    $data = [
        'name' => 'Test',
    ];

    $this->actingAs($user)->
    post(route('characters.store'), $data);

    $this->assertDatabaseHas(Character::class, [
        'name' => 'Test',
    ]);
});

Here is the controller:

 /**
     * Store a newly created resource in storage.
     */
    public function store(StoreCharacterRequest $request): Response|RedirectResponse
    {
        // get the current player's characters count
        $count = $this->countCharacter(auth()->user()->id);

        // if the play is already at max characters
        if ($count >= config("characters.max_allowed")) {

            // display an error instead
            return inertia('Characters/MaxCharacters');
        }

        // retrieve only the name and active status from the request
        $validated = $request->safe()->only(['name', 'active']);

        // if the player already has a character
        if($count) {

            // if the active checkbox was ticked
            if($validated['active']) {

                // get the player's active character if any
                $character = Character::where('user_id', auth()->user()->id)->where('active', 1)->first();

                // set the current one as not active
                $character->update(['active' => 0]);

                // and save the changes to the database
                $character->save();
            }
            // otherwise this is the player's 1st character
        } else {
            // so mark the character as active
            $validated['active'] = 1;
        }

        // get the first belt
        $belt = Belt::where('min_xp', '<=', 1)->first();

        // and create the players character
        Character::create([
            'name' => $validated['name'],
            'user_id' => auth()->user()->id,
            'belt_id' => $belt->id,
            'active' => $validated['active'],

        ]);

        // then redirect to the characters index
        return redirect('characters');
    }

Here is the character model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;

class Character extends Model
{
    use HasFactory;
    use SoftDeletes;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'user_id',
        'active',
        'name',
        'belt_id',
    ];

    public function user() : BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function belt(): BelongsTo
    {
        return $this->belongsTo(Belt::class);
    }
}

It's migration:

<?php

use App\Models\Belt;
use App\Models\User;
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('characters', function (Blueprint $table) {
            $table->id();
            $table->foreignIdFor(User::class)->constrained();
            $table->boolean("active")->default(false);
            $table->string("name", 15);
            $table->integer("xp")->default(0);
            $table->foreignIdFor(Belt::class)->constrained();
            $table->integer("wins")->default(0);
            $table->integer("loses")->default(0);
            $table->integer("draws")->default(0);
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('characters');
    }
};

and the character factory:

<?php

namespace Database\Factories;

use App\Models\Belt;
use App\Models\Character;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Log;

/**
 * @extends Factory<Character>
 */
class CharacterFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $minXp = Belt::min('min_xp') ?? 0;
        $maxXp = Belt::max('max_xp') ?? 50;

        $xp = fake()->numberBetween($minXp , $maxXp);

        $belt = Belt::where('min_xp', '<=', $xp)->orderBy('min_xp', 'desc')->first();

        return [
            'user_id' => User::factory(),
            'name' => fake()->randomAscii(0, 25),
            'xp' => $xp,
            'belt_id' => $belt->id,
            'wins' => fake()->numberBetween(0, 99),
            'loses' => fake()->numberBetween(0, 99),
            'draws' => fake()->numberBetween(0, 99)
        ];
    }
}

it's belt bependency model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Belt extends Model
{
    use HasFactory;
}

The belt migration:

<?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('belts', function (Blueprint $table) {
            $table->id();
            $table->string("name", 32)->unique();
            $table->string("image");
            $table->integer("min_xp");
            $table->integer("max_xp");
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('belts');
    }
};

and the belt factory:

<?php

namespace Database\Factories;

use App\Models\Belt;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Belt>
 */
class BeltFactory extends Factory
{
    // static variable to keep track if this is the 1st belt instance seeded
    protected static bool $firstInstance = true;

    // Static variable to keep track of the last max XP
    protected static int $lastMaxXp = 0;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        // random element array
        $randomElements = ['Amazing', 'Devine', 'Exquisite', 'Great', 'Marvelous', 'Radiant', 'Shining', 'Vibrant'];

        // generate a random element for the belt
        $randomElement = fake()->randomElements($randomElements);

        // generate a random color for the belt
        $colorName = fake()->colorName();

        // generate the name using the random element and color name
        $name = $randomElement[0] . " " . $colorName;

        // generate url safe image name using the random element and color name
        $image_url = $randomElement[0] . "_" . $colorName;

        // if this is the first belt instance
        if(self::$firstInstance) {
            // Set the minimum XP to be 0
            $minXp = 0;

            // and set firstInstance to false for the next instance
            self::$firstInstance = false;
        } else {
            // Otherwise set the minimum XP to be one more than the last max XP
            $minXp = self::$lastMaxXp + 1;
        }

        // Generate a max XP that is greater than the min XP
        // For example, you can use a random number between min XP + 20 and min XP + 50
        $maxXp = $minXp + fake()->numberBetween(20, 50);

        // Update the last max XP for the next belt
        self::$lastMaxXp = $maxXp;

        return [
            'name' => $name,
            'image' => storage_path('app/public/belts/' . $image_url . '.png'),
            'min_xp' => $minXp,
            'max_xp' => $maxXp,
        ];
    }
}

Line 99:

 // get the first belt
        $belt = Belt::where('min_xp', '<=', 1)->first();

        // and create the players character
        Character::create([
            'name' => $validated['name'],
            'user_id' => auth()->user()->id,
            'belt_id' => $belt->id, // <----- Line 99
            'active' => $validated['active'],

        ]);

Altering the test to:

it('Stores a character', function () {
    $this->withoutExceptionHandling();

    // Arrange
    $user = User::factory()->create();
    $belt = Belt::factory()->create();

    $data = [
        'name' => 'Test',
    ];

    $response = $this->actingAs($user)->post(route('characters.store'), $data);

    dd($response);

    // Check if redirection happens as expected
    $response->assertRedirect('characters');

    $this->assertDatabaseHas(Character::class, [
        'name' => 'Test',
    ]);

Produces the error:

C:\laragon\bin\php\php-8.2.17-Win32-vs16-x64\php.exe C:\laragon\www\Ookma-Kyi-Core\vendor\pestphp\pest\bin\pest --teamcity --configuration C:\laragon\www\Ookma-Kyi-Core\phpunit.xml

ErrorException: Attempt to read property "id" on null
at vendor\laravel\framework\src\Illuminate\Foundation\Bootstrap\HandleExceptions.php:256
at app\Http\Controllers\CharacterController.php:99
at vendor\laravel\framework\src\Illuminate\Routing\Controller.php:54
at vendor\laravel\framework\src\Illuminate\Routing\ControllerDispatcher.php:43
at vendor\laravel\framework\src\Illuminate\Routing\Route.php:260
at vendor\laravel\framework\src\Illuminate\Routing\Route.php:206
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:806
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:144
at vendor\laravel\framework\src\Illuminate\Auth\Middleware\EnsureEmailIsVerified.php:41
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets.php:19
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\inertiajs\inertia-laravel\src\Middleware.php:87
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\jetstream\src\Http\Middleware\ShareInertiaData.php:69
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Auth\Middleware\Authorize.php:57
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Routing\Middleware\SubstituteBindings.php:50
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Session\Middleware\AuthenticateSession.php:67
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Auth\Middleware\Authenticate.php:64
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken.php:81
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\View\Middleware\ShareErrorsFromSession.php:49
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Session\Middleware\StartSession.php:121
at vendor\laravel\framework\src\Illuminate\Session\Middleware\StartSession.php:64
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse.php:37
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Cookie\Middleware\EncryptCookies.php:75
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:119
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:805
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:784
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:748
at vendor\laravel\framework\src\Illuminate\Routing\Router.php:737
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Kernel.php:200
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:144
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\TransformsRequest.php:21
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull.php:31
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\TransformsRequest.php:21
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\TrimStrings.php:50
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Http\Middleware\ValidatePostSize.php:27
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance.php:103
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Http\Middleware\HandleCors.php:49
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Http\Middleware\TrustProxies.php:57
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:183
at vendor\laravel\framework\src\Illuminate\Pipeline\Pipeline.php:119
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Kernel.php:175
at vendor\laravel\framework\src\Illuminate\Foundation\Http\Kernel.php:144
at vendor\laravel\framework\src\Illuminate\Foundation\Testing\Concerns\MakesHttpRequests.php:585
at vendor\laravel\framework\src\Illuminate\Foundation\Testing\Concerns\MakesHttpRequests.php:375
at tests\Feature\Controllers\CharacterController\StoreTest.php:36

I have no clue what I'm doing wrong as I'm following something similar to the build a forum series from Laracasts with the revelant code. Any ideas?

bobbyiliev

May 8th, 2024 11:38 AM

Hey!

The error you're encountering (Attempt to read property "id" on null) indicates that the variable $belt is null when trying to access the id property. The issue stems from this code block in the store method:

// get the first belt
$belt = Belt::where('min_xp', '<=', 1)->first();

// and create the player's character
Character::create([
    'name' => $validated['name'],
    'user_id' => auth()->user()->id,
    'belt_id' => $belt->id, 
    'active' => $validated['active'],
]);

The root cause is that the Belt table does not have any entries that match the condition where('min_xp', '<=', 1), leading to the query returning null.

You should ensure that the Belt table contains at least one belt with a min_xp value less than or equal to 1. This way, the query will return a valid Belt instance, and the subsequent code will work as expected:

it('Stores a character', function () {
    $this->withoutExceptionHandling();

    // Arrange
    $user = User::factory()->create();
    $belt = Belt::factory()->create([
        'name' => 'White Belt',
        'image' => 'white_belt.png',
        'min_xp' => 0,
        'max_xp' => 10,
    ]);

    $data = [
        'name' => 'Test',
    ];

    // Act
    $response = $this->actingAs($user)->post(route('characters.store'), $data);

    // Assert
    $response->assertRedirect('characters');

    $this->assertDatabaseHas(Character::class, [
        'name' => 'Test',
        'belt_id' => $belt->id,
    ]);
});

Make sure the BeltFactory creates valid belts:

namespace Database\Factories;

use App\Models\Belt;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Belt>
 */
class BeltFactory extends Factory
{
    protected $model = Belt::class;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        static $lastMaxXp = 0;

        $minXp = $lastMaxXp + 1;
        $maxXp = $minXp + $this->faker->numberBetween(10, 20);

        $lastMaxXp = $maxXp;

        return [
            'name' => $this->faker->unique()->colorName() . ' Belt',
            'image' => 'belt_image.png',
            'min_xp' => $minXp,
            'max_xp' => $maxXp,
        ];
    }
}

Let me know how this goes! 🚀

ookma-kyi

May 9th, 2024 01:47 PM

Wow, you're good! Can you please help me understand something:

<?php

namespace Database\Factories;

use App\Models\Belt;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Belt>
 */
class BeltFactory extends Factory
{
    // static variable to keep track if this is the 1st belt instance seeded
    protected static bool $firstInstance = true;

    // Static variable to keep track of the last max XP
    protected static int $lastMaxXp = 0;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        // random element array
        $randomElements = ['Amazing', 'Devine', 'Exquisite', 'Great', 'Marvelous', 'Radiant', 'Shining', 'Vibrant'];

        // generate a random element for the belt
        $randomElement = fake()->randomElements($randomElements);

        // generate a random color for the belt
        $colorName = fake()->colorName();

        // generate the name using the random element and color name
        $name = $randomElement[0] . " " . $colorName;

        // generate url safe image name using the random element and color name
        $image_url = $randomElement[0] . "_" . $colorName;

        // if this is the first belt instance
        if(self::$firstInstance) {
            // Set the minimum XP to be 0
            $minXp = 0;

            // and set firstInstance to false for the next instance
            self::$firstInstance = false;
        } else {
            // Otherwise set the minimum XP to be one more than the last max XP
            $minXp = self::$lastMaxXp + 1;
        }

        // Generate a max XP that is greater than the min XP
        // For example, you can use a random number between min XP + 20 and min XP + 50
        $maxXp = $minXp + fake()->numberBetween(20, 50);

        // Update the last max XP for the next belt
        self::$lastMaxXp = $maxXp;

        return [
            'name' => $name,
            'image' => storage_path('app/public/belts/' . $image_url . '.png'),
            'min_xp' => $minXp,
            'max_xp' => $maxXp,
        ];
    }
}

On Line 42 I have:

        // if this is the first belt instance
        if(self::$firstInstance) {
            // Set the minimum XP to be 0
            $minXp = 0;

            // and set firstInstance to false for the next instance
            self::$firstInstance = false;
        } else {
            // Otherwise set the minimum XP to be one more than the last max XP
            $minXp = self::$lastMaxXp + 1;
        }

Since this is the first and only instance of BeltFactory being instantiated in that particular test:

it('Stores a character', function () {
    // Arrange
    $user = User::factory()->create();
    /** HERE */
    $belt = Belt::factory()->create([
        'min_xp' => 0,
    ]);

    $data = [
        'name' => 'Test',
    ];

    $response = $this->actingAs($user)->post(route('characters.store'), $data);

    // Check if redirection happens as expected
    $response->assertRedirect('characters');

    $this->assertDatabaseHas(Character::class, [
        'name' => 'Test',
    ]);
});

min_xp should automatically be set to 0 for that test yet it isn't. Could you in your infinite wisdom explain why?

bobbyiliev

May 10th, 2024 12:13 AM

Hey!

I believe that in the case you're describing, the BeltFactory does not automatically assign min_xp to 0 because the static variable firstInstance is not considered when using the create method directly with custom attributes.