Rebuilding my invoicing application in Laravel, Part 5 - Installation and Authentication

The application needs a way for a user to login and interact with the application. What I would like to do is check for a user in the database and if none exist then show them the "install" screen.

Goal

The application needs a way for a user to login and interact with the application. What I would like to do is check for a user in the database and if none exist then show them the "install" screen. This screen should allow the user to register but also obtain intial settings such as rate, timezone and company. Again, this is scoped as a self install application for one user.

Tests and Development

php artisan make:test InstallTest

tests/Feature/InstallTest.php

class InstallTest extends TestCase
{
    use RefreshDatabase, ValidationHelperTrait;

    /**
     * @test
     */
    public function visitor_redirected_to_install_page_when_the_users_table_is_empty()
    {

    }

    /**
     * @test
     */
    public function install_page_can_not_be_reached_if_user_table_is_not_empty()
    {

    }

    /**
     * @test
     */
    public function installation_data_can_only_be_stored_if_the_users_table_is_empty()
    {

    }

    /**
     * @test
     */
    public function install_page_loads_when_the_users_table_is_empty()
    {

    }

    /**
     * @test
     */
    public function installation_stores_a_new_user()
    {

    }

    /**
     * @test
     */
    public function installation_stores_new_invoice_settings()
    {

    }

    /**
     * @test
     */
    public function successful_installation_will_redirect_user()
    {

    }

    /**
     * @test
     */
    public function storing_installation_data_requires_an_email()
    {

    }

    /**
     * @test
     */
    public function storing_installation_data_requires_a_password()
    {

    }

    /**
     * @test
     */
    public function storing_installation_data_requires_a_timezone()
    {

    }

    /**
     * @test
     */
    public function storing_installation_data_the_email_must_be_valid()
    {

    }

    /**
     * @test
     */
    public function storing_installation_data_the_timezone_must_be_valid()
    {

    }
}

Let's start with the visitor_redirected_to_install_page_when_the_users_table_is_empty test. Since most pages will redirect to a login page if authenication fails, a simple solution is just to check for an existing user. So basically the /login uri should redirect to /install.

/**
 * @test
 */
public function install_page_loads_when_the_users_table_is_empty()
{
    $this->call('GET', 'login')->assertRedirect('install');
}

Before running this test and getting a 404, I'll just go ahead and setup Laravel's authentication scaffolding.

php artisan make:auth

This will setup all the routes and views need for handling user authentication. Also, I don't want to allow registration and will disable it by implimenting a couple new methods to replace the defaults.

app/Http/Controller/Auth/RegisterController.php

/**
 * Redirect to login form
 *
 * @return \Illuminate\Http\Response
 */
public function showRegistrationForm()
{
    return redirect('login');
}

/**
 * Redirect to login form
 *
 * @param  \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */
public function register(Request $request)
{
    return redirect('login');
}

Ok, now let's run the test.

phpunit --filter install_page_loads_when_the_users_table_is_empty

Response status code [200] is not a redirect status code.

This is expected since we haven't set this up yet. I'll make the update.

app/Http/Controllers/Auth/LoginController.php

/**
 * Show the application's login form.
 *
 * @return \Illuminate\Http\Response
 */
public function showLoginForm()
{
    if(User::all()->isEmpty()) return redirect('install');

    return view('auth.login');
}

I'll just overwrite the existing showLoginForm method and check the users table.

phpunit --filter install_page_loads_when_the_users_table_is_empty

OK (1 test, 2 assertions)

On to the next test.

/**
 * @test
 */
public function install_page_can_not_be_reached_if_user_table_is_not_empty()
{
    $this->createUser();

    $this->call('GET', 'install')->assertRedirect('login');
}

routes/web.php

Route::get('install', 'InstallationController@getInstallForm');
php artisan make:controller InstallationController

app/Http/Controllers/InstallationController.php

public function getInstallForm()
{
    if(User::all()->count()) return redirect('login');
}

All good to go. The next test is basically the same but just tested against the POST uri.

/**
 * @test
 */
public function installation_data_can_only_be_stored_if_the_users_table_is_empty()
{
    $this->createUser();

    $data = [
        'name' => 'Test McTesterson',
        'email' => 'test@test.com',
        'password' => 'testing',
        'timezone' => 'America/Chicago',
        'company' => 'Test Co.'
    ];

    $this->call('POST', 'install', $data)->assertRedirect('login');
}

routes/web.php

Route::post('install', 'InstallationController@install');

app/Http/Controllers/InstallationController.php

public function install()
{
    if(User::all()->count()) return redirect('login');
}

Next, I need to make sure the view is returned when /install is reach with an empty users table.

/**
 * @test
 */
public function install_page_loads_when_the_users_table_is_empty()
{
    $this->call('GET', 'install')->assertViewIs('install');
}

app/Http/Controllers/InstallController.php

public function getInstallForm()
{
    if(User::all()->count()) return redirect('login');

    return view('install');
}
touch resources/views/install.blade.php

Ok, next I'll work on the actually install. I have fields for both the user and invoice settings.

/**
 * @test
 */
public function installation_stores_a_new_user()
{
    $data = [
        'name' => 'Test McTesterson',
        'email' => 'test@test.com',
        'password' => 'testing',
        'timezone' => 'America/Chicago',
        'company' => 'Test Co.'
    ];

    $this->call('POST', 'install', $data);

    $this->assertDatabaseHas('users', array_except($data, ['company', 'password']));
}

I'll setup some basic code to make this work.

app/Http/Controllers/InstallationController.php

public function install(Request $request)
{
    if (User::all()->count()) return redirect('login');

    $request->offsetSet('password', bcrypt($request->password));

    $this->user->create($request->all());
}

After running this test I notice an error in the logs.

Integrity constraint violation: 19 NOT NULL constraint failed: users.rate

So I'll just set a default.

database/migrations/{DATE}_create_users_table.php

$table->unsignedInteger('rate')->default(100);

Looks like that did it! Ok, almost there. This next tests just makes sure the invoice settings are created.

/**
 * @test
 */
public function installation_stores_new_invoice_settings()
{
    $data = [
        'name' => 'Test McTesterson',
        'email' => 'test@test.com',
        'password' => 'testing',
        'timezone' => 'America/Chicago',
        'company' => 'Test Co.'
    ];

    $this->call('POST', 'install', $data);

    $this->assertDatabaseHas('invoice_settings', array_only($data, ['company']));
}

After running this test and looking at the logs, it appears we need to set several fields to NOT NULL. The invoice really just needs the company name at the minimum. So I'll update the migration.

database/migrations/{DATE}_create_invoice_settings_table.php

Schema::create('invoice_settings', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('user_id');
    $table->string('logo')->nullable();
    $table->string('company');
    $table->string('email')->nullable();
    $table->string('address_1')->nullable();
    $table->string('address_2')->nullable();
    $table->string('city')->nullable();
    $table->string('state')->nullable();
    $table->string('zip')->nullable();
    $table->string('phone')->nullable();
    $table->text('note')->nullable();
    $table->timestamps();

    $table->foreign('user_id')->references('id')->on('users')->onUpdate('cascade')->onDelete('cascade');
});

Yep, that did it! After installation is complete, we should authenticate the user and redirect to the home page.

/**
 * @test
 */
public function successful_installation_will_redirect_user()
{
    $data = [
        'name' => 'Test McTesterson',
        'email' => 'test@test.com',
        'password' => 'testing',
        'timezone' => 'America/Chicago',
        'company' => 'Test Co.'
    ];

    $this->call('POST', 'install', $data)->assertRedirect('/');
}

app/Http/Controllers/InstallationController.php

public function install(Request $request)
{
    if (User::all()->count()) return redirect('login');

    $request->offsetSet('password', bcrypt($request->password));

    $user = $this->user->create($request->all());

    $user->invoiceSettings()->create($request->all());

    Auth::login($user);

    return redirect('/');
}

Refactor

Ok, I'm going to do a little clean up. First, I'll make a little helper to reduce the repeated install check and make it read better.

app/Http/Controllers/InstallationController.php

/**
 * @return int
 */
private function needsInstallation() : bool
{
    return (bool) $this->user->all()->isEmpty();
}

I don't like that my controller is doing the actually work of performing the installation. I'll make a special class that handles this process.

app/Installers/Installer.php

<?php

namespace Invoicing\Installers;

use Invoicing\User;

class Installer {

    protected $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function install($settings)
    {
        $user = $this->user->create($settings);

        $user->invoiceSettings()->create($settings);

        return $user;
    }
}

app/Http/Controllers/InstallationController.php

public function install(InstallationRequest $request)
{
    if (! $this->needsInstallation()) return redirect('login');

    $request->offsetSet('password', bcrypt($request->password));

    $user = $this->installer->install($request->all());

    Auth::login($user);

    return redirect('/');
}

But now I am looking at this thinking, "Why can't the Installer class tell me if it can install?". I'll move this method to the Installer class and update the controller methods.

app/Installers/Installer.php

/**
 * @return bool
 */
public function needsInstallation() : bool
{
    return (bool) $this->user->all()->isEmpty();
}

I'll update ...

$this->needsInstallation()

to

$this->installer->needsInstallation()

and call it good.

2018 Phil Mareu - Coder, Traveler & Disc Thrower