Blog header image

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

Posted on Dec 5th, 2017

## 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 ```console php artisan make:test InstallTest ``` `tests/Feature/InstallTest.php` ```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`. ```php /** * @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. ```console 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` ```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. ```console 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` ```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. ```console phpunit --filter install_page_loads_when_the_users_table_is_empty OK (1 test, 2 assertions) ``` On to the next test. ```php /** * @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` ```php Route::get('install', 'InstallationController@getInstallForm'); ``` ```console php artisan make:controller InstallationController ``` `app/Http/Controllers/InstallationController.php` ```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. ```php /** * @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` ```php Route::post('install', 'InstallationController@install'); ``` `app/Http/Controllers/InstallationController.php` ```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. ```php /** * @test */ public function install_page_loads_when_the_users_table_is_empty() { $this->call('GET', 'install')->assertViewIs('install'); } ``` `app/Http/Controllers/InstallController.php` ```php public function getInstallForm() { if(User::all()->count()) return redirect('login'); return view('install'); } ``` ```console 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. ```php /** * @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` ```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` ```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. ```php /** * @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` ```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. ```php /** * @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` ```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` ```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 user = $user; } public function install($settings) { $user = $this->user->create($settings); $user->invoiceSettings()->create($settings); return $user; } } ``` `app/Http/Controllers/InstallationController.php` ```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` ```php /** * @return bool */ public function needsInstallation() : bool { return (bool) $this->user->all()->isEmpty(); } ``` I'll update ... ```php $this->needsInstallation() ``` to ```php $this->installer->needsInstallation() ``` and call it good.