diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index 4a464ab6..d4d6ee53 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,376 +1,370 @@ diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue index 01cbd251..bb92d8f8 100644 --- a/src/resources/vue/User/Profile.vue +++ b/src/resources/vue/User/Profile.vue @@ -1,114 +1,113 @@ diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 830b2202..d2af9a9b 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,207 +1,228 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->waitFor('div.tooltip .tooltip-inner') ->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert Toast element content (and close it) */ public function assertToast(string $type, string $message, $title = null) { return $this->withinBody(function ($browser) use ($type, $title, $message) { $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { $browser->assertToastTitle($title) ->assertToastMessage($message) ->closeToast(); }); }); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) { $this->with(new Error($error_code), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("jQuery('.toast-container > *').remove()"); return $this; } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename, $sleep = 5) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename) && $sleep) { sleep($sleep); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } + /** + * Clears the input field and related vue v-model data. + */ + public function vueClear($selector) + { + if ($this->resolver->prefix != 'body') { + $selector = $this->resolver->prefix . ' ' . $selector; + } + + // The existing clear(), and type() with empty string do not work. + // We have to clear the field and dispatch 'input' event programatically. + + $this->script( + "var element = document.querySelector('$selector');" + . "element.value = '';" + . "element.dispatchEvent(new Event('input'))" + ); + + return $this; + } + /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php index 40dd59e7..22e25b9d 100644 --- a/src/tests/Browser/UserProfileTest.php +++ b/src/tests/Browser/UserProfileTest.php @@ -1,191 +1,191 @@ 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'phone' => '+1 509-248-1111', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); $this->deleteTestUser('profile-delete@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); $this->deleteTestUser('profile-delete@kolabnow.com'); parent::tearDown(); } /** * Test profile page (unauthenticated) */ public function testProfileUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/profile')->on(new Home()); }); } /** * Test profile page */ public function testProfile(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-profile', 'Your profile') ->click('@links .link-profile') ->on(new UserProfile()) ->assertSeeIn('#user-profile .button-delete', 'Delete account') ->whenAvailable('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(4) label', 'Phone') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['phone']) ->assertSeeIn('div.row:nth-child(5) label', 'External email') ->assertValue('div.row:nth-child(5) input[type=text]', $this->profile['external_email']) ->assertSeeIn('div.row:nth-child(6) label', 'Address') ->assertValue('div.row:nth-child(6) textarea', $this->profile['billing_address']) ->assertSeeIn('div.row:nth-child(7) label', 'Country') ->assertValue('div.row:nth-child(7) select', $this->profile['country']) ->assertSeeIn('div.row:nth-child(8) label', 'Password') ->assertValue('div.row:nth-child(8) input[type=password]', '') ->assertSeeIn('div.row:nth-child(9) label', 'Confirm password') ->assertValue('div.row:nth-child(9) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit'); + // Test form error handling + $browser->type('#phone', 'aaaaaa') + ->type('#external_email', 'bbbbb') + ->click('button[type=submit]') + ->waitFor('#phone + .invalid-feedback') + ->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.') + ->assertSeeIn( + '#external_email + .invalid-feedback', + 'The external email must be a valid email address.' + ) + ->assertFocused('#phone') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->clearToasts(); + // Clear all fields and submit // FIXME: Should any of these fields be required? - $browser->type('#first_name', '') - ->type('#last_name', '') - ->type('#organization', '') - ->type('#phone', '') - ->type('#external_email', '') - ->type('#billing_address', '') - ->select('#country', '') - ->click('button[type=submit]'); + $browser->vueClear('#first_name') + ->vueClear('#last_name') + ->vueClear('#organization') + ->vueClear('#phone') + ->vueClear('#external_email') + ->vueClear('#billing_address') + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - - // Test error handling - $browser->with('@form', function (Browser $browser) { - $browser->type('#phone', 'aaaaaa') - ->type('#external_email', 'bbbbb') - ->click('button[type=submit]') - ->waitFor('#phone + .invalid-feedback') - ->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.') - ->assertSeeIn( - '#external_email + .invalid-feedback', - 'The external email must be a valid email address.' - ) - ->assertFocused('#phone') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - }); + // On success we're redirected to Dashboard + ->on(new Dashboard()); }); } /** * Test profile of non-controller user */ public function testProfileNonController(): void { // Test acting as non-controller $this->browse(function (Browser $browser) { $browser->visit('/logout') ->visit(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-profile', 'Your profile') ->click('@links .link-profile') ->on(new UserProfile()) ->assertMissing('#user-profile .button-delete') ->whenAvailable('@form', function (Browser $browser) { // TODO: decide on what fields the non-controller user should be able // to see/change }); // Test that /profile/delete page is not accessible $browser->visit('/profile/delete') ->assertErrorPage(403); }); } /** * Test profile delete page */ public function testProfileDelete(): void { $user = $this->getTestUser('profile-delete@kolabnow.com', ['password' => 'simple123']); $this->browse(function (Browser $browser) use ($user) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('profile-delete@kolabnow.com', 'simple123', true) ->on(new Dashboard()) ->clearToasts() ->assertSeeIn('@links .link-profile', 'Your profile') ->click('@links .link-profile') ->on(new UserProfile()) ->click('#user-profile .button-delete') ->waitForLocation('/profile/delete') ->assertSeeIn('#user-delete .card-title', 'Delete this account?') ->assertSeeIn('#user-delete .button-cancel', 'Cancel') ->assertSeeIn('#user-delete .card-text', 'This operation is irreversible') ->assertFocused('#user-delete .button-cancel') ->click('#user-delete .button-cancel') ->waitForLocation('/profile') ->on(new UserProfile()); // Test deleting the user $browser->click('#user-profile .button-delete') ->waitForLocation('/profile/delete') ->click('#user-delete .button-delete') ->waitForLocation('/login') ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.'); $this->assertTrue($user->fresh()->trashed()); }); } // TODO: Test that Ned (John's "delegatee") can delete himself // TODO: Test that Ned (John's "delegatee") can/can't delete John ? } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 3dc8a94d..9abd9b48 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,564 +1,563 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); $browser->visit('/user/' . $user->id)->on(new Home()); }); } /** * Test users list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/users')->on(new Home()); }); } /** * Test users list page */ public function testList(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-users', 'User accounts') ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(1) button.button-delete') ->assertVisible('tbody tr:nth-child(2) button.button-delete') ->assertVisible('tbody tr:nth-child(3) button.button-delete') ->assertVisible('tbody tr:nth-child(4) button.button-delete') ->assertMissing('tfoot'); }); }); } /** * Test user account editing page (not profile page) * * @depends testList */ public function testInfo(): void { $this->browse(function (Browser $browser) { $browser->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Organization') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(5) label', 'Email') ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(5) input[type=text]') ->assertSeeIn('div.row:nth-child(6) label', 'Email aliases') ->assertVisible('div.row:nth-child(6) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm password') ->assertValue('div.row:nth-child(8) input[type=password]', '') - ->assertSeeIn('button[type=submit]', 'Submit'); - - // Clear some fields and submit - $browser->type('#first_name', '') - ->type('#last_name', '') + ->assertSeeIn('button[type=submit]', 'Submit') + // Clear some fields and submit + ->vueClear('#first_name') + ->vueClear('#last_name') ->click('button[type=submit]'); }) - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') + ->on(new UserList()) + ->click('@table tr:nth-child(3) a') + ->on(new UserInfo()) + ->assertSeeIn('#user-info .card-title', 'User account') + ->with('@form', function (Browser $browser) { + // Test error handling (password) + $browser->type('#password', 'aaaaaa') + ->vueClear('#password_confirmation') + ->click('button[type=submit]') + ->waitFor('#password + .invalid-feedback') + ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') + ->assertFocused('#password') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - // Test error handling (password) - $browser->with('@form', function (Browser $browser) { - $browser->type('#password', 'aaaaaa') - ->type('#password_confirmation', '') - ->click('button[type=submit]') - ->waitFor('#password + .invalid-feedback') - ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') - ->assertFocused('#password') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - }); + // TODO: Test password change - // TODO: Test password change + // Test form error handling (aliases) + $browser->vueClear('#password') + ->vueClear('#password_confirmation') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->addListEntry('invalid address'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - // Test form error handling (aliases) - $browser->with('@form', function (Browser $browser) { - // TODO: For some reason, clearing the input value - // with ->type('#password', '') does not work, maybe some dusk/vue intricacy - // For now we just use the default password - $browser->type('#password', 'simple123') - ->type('#password_confirmation', 'simple123') - ->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->addListEntry('invalid address'); - }) - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - }) - ->with('@form', function (Browser $browser) { - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->assertFormError(2, 'The specified alias is invalid.', false); - }); - }); + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertFormError(2, 'The specified alias is invalid.', false); + }); - // Test adding aliases - $browser->with('@form', function (Browser $browser) { - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->removeListEntry(2) - ->addListEntry('john.test@kolab.org'); + // Test adding aliases + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->removeListEntry(2) + ->addListEntry('john.test@kolab.org'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - }); + ->on(new UserList()) + ->click('@table tr:nth-child(3) a') + ->on(new UserInfo()); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(9)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // Storage SKU ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', 'Some wiggle room' ) ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { $browser->assertQuotaValue(2)->setQuotaValue(3); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') ->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') ->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', 'Mobile synchronization' ) // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(5) td.selection input') ->assertEnabled('tbody tr:nth-child(5) td.selection input') ->assertTip( 'tbody tr:nth-child(5) td.buttons button', 'Two factor authentication for webmail and administration panel' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - }); + }) + ->on(new UserList()) + ->click('@table tr:nth-child(3) a') + ->on(new UserInfo()); $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage']; $this->assertUserEntitlements($john, $expected); // Test subscriptions interaction $browser->with('@form', function (Browser $browser) { $browser->with('@skus', function ($browser) { // Uncheck 'groupware', expect activesync unchecked $browser->click('#sku-input-groupware') ->assertNotChecked('#sku-input-groupware') ->assertNotChecked('#sku-input-activesync') ->assertEnabled('#sku-input-activesync') ->assertNotReadonly('#sku-input-activesync') // Check 'activesync', expect an alert ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-activesync') // Check '2FA', expect 'activesync' unchecked and readonly ->click('#sku-input-2fa') ->assertChecked('#sku-input-2fa') ->assertNotChecked('#sku-input-activesync') ->assertReadonly('#sku-input-activesync') // Uncheck '2FA' ->click('#sku-input-2fa') ->assertNotChecked('#sku-input-2fa') ->assertNotReadonly('#sku-input-activesync'); }); }); }); } /** * Test user adding page * * @depends testList */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@form', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list - ->waitForLocation('/users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org') ->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->whenAvailable('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); // Test clicking Delete on the controller record redirects to /profile/delete $browser ->with('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(3) button.button-delete'); }) ->waitForLocation('/profile/delete'); }); // Test that non-controller user cannot see/delete himself on the users list // Note: Access to /profile/delete page is tested in UserProfileTest.php $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 0) ->assertSeeIn('tfoot td', 'There are no users in this account.'); }); }); // Test that controller user (Ned) can see/delete all the users ??? $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertElementsCount('tbody button.button-delete', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } }