diff --git a/src/package.json b/src/package.json index 459f3f07..29c2131c 100644 --- a/src/package.json +++ b/src/package.json @@ -1,39 +1,37 @@ { "private": true, "scripts": { "dev": "npm run development", "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", "watch": "npm run development -- --watch", "watch-poll": "npm run watch -- --watch-poll", "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", "prod": "npm run production", "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", "lint": "eslint --ext .js,.vue resources && stylelint \"resources/sass/*.scss\" \"resources/vue/*.vue\"" }, "devDependencies": { - "@deveodk/vue-toastr": "^1.1.0", "axios": "^0.19", "bootstrap": "^4.4.1", "cross-env": "^5.1", "eslint": "^6.8.0", "eslint-plugin-vue": "^6.1.1", "@fortawesome/fontawesome-svg-core": "^1.2.27", "@fortawesome/free-brands-svg-icons": "^5.12.1", "@fortawesome/free-regular-svg-icons": "^5.12.1", "@fortawesome/free-solid-svg-icons": "^5.12.1", "@fortawesome/vue-fontawesome": "^0.1.9", "jquery": "^3.4.1", "laravel-mix": "^4.0.7", - "lodash": "^4.17.13", "popper.js": "^1.12", "resolve-url-loader": "^2.3.1", "sass": "^1.15.2", "sass-loader": "^7.1.0", "stylelint": "^12.0.1", "stylelint-config-standard": "^19.0.0", "vue": "^2.5.17", "vue-router": "^3.1.3", "vue-template-compiler": "^2.6.10", "vuex": "^3.1.1" } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index e03c518b..0f185585 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,266 +1,266 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' -import MenuComponent from '../vue/Menu' +import MenuComponent from '../vue/Widgets/Menu' import store from './store' const app = new Vue({ el: '#app', components: { - 'app-component': AppComponent, - 'menu-component': MenuComponent + AppComponent, + MenuComponent, }, store, router: window.router, data() { return { isLoading: true, isAdmin: window.isAdmin } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) }, // Display "loading" overlay (to be used by route components) startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element let loading = $('#app > .app-loader').show() if (!loading.length) { $('#app').append($('
Loading
')) } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } }, price(price) { return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { let error_msg let status = error.response ? error.response.status : 200 if (error.response && status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { $('form').each((i, form) => { const input_name = ($(form).data('validation-prefix') || '') + idx const input = $('#' + input_name) if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } return false } }); }) $('form .is-invalid:not(.listinput-widget)').first().focus() } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } - app.$toastr('error', error_msg || "Server Error", 'Error') + app.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/bootstrap.js b/src/resources/js/bootstrap.js index f5648f25..0b749d55 100644 --- a/src/resources/js/bootstrap.js +++ b/src/resources/js/bootstrap.js @@ -1,89 +1,81 @@ -window._ = require('lodash') - /** * We'll load jQuery and the Bootstrap jQuery plugin which provides support * for JavaScript based Bootstrap features such as modals and tabs. This * code may be modified to fit the specific needs of your application. */ -try { - window.Popper = require('popper.js').default - window.$ = window.jQuery = require('jquery') +window.Popper = require('popper.js').default +window.$ = window.jQuery = require('jquery') - require('bootstrap') -} catch (e) {} +require('bootstrap') /** * We'll load Vue, VueRouter and global components */ import FontAwesomeIcon from './fontawesome' import VueRouter from 'vue-router' -import VueToastr from '@deveodk/vue-toastr' +import Toast from '../vue/Widgets/Toast' import store from './store' window.Vue = require('vue') Vue.component('svg-icon', FontAwesomeIcon) -Vue.use(VueToastr, { - defaultPosition: 'toast-bottom-right', - defaultTimeout: 5000 -}) - const vTooltip = (el, binding) => { const t = [] if (binding.modifiers.focus) t.push('focus') if (binding.modifiers.hover) t.push('hover') if (binding.modifiers.click) t.push('click') if (!t.length) t.push('hover') $(el).tooltip({ title: binding.value, placement: binding.arg || 'top', trigger: t.join(' '), html: !!binding.modifiers.html, }); } Vue.directive('tooltip', { bind: vTooltip, update: vTooltip, unbind (el) { $(el).tooltip('dispose') } }) +Vue.use(Toast) Vue.use(VueRouter) window.router = new VueRouter({ mode: 'history', routes: window.routes }) router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } next() }) /** * We'll load the axios HTTP library which allows us to easily issue requests * to our Laravel back-end. This library automatically handles sending the * CSRF token as a header based on the value of the "XSRF" token cookie. */ window.axios = require('axios') axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js index 8ad6b531..6ebbb073 100644 --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -1,47 +1,51 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, faSquare, } from '@fortawesome/free-regular-svg-icons' import { faCheck, + faCheckCircle, faGlobe, + faExclamationCircle, faInfoCircle, faLock, faKey, faPlus, faSearch, faSignInAlt, faSyncAlt, faTrashAlt, faUser, faUserCog, faUsers, faWallet } from '@fortawesome/free-solid-svg-icons' // Register only these icons we need library.add( - faCheckSquare, faCheck, + faCheckCircle, + faCheckSquare, + faExclamationCircle, faGlobe, faInfoCircle, faLock, faKey, faPlus, faSearch, faSignInAlt, faSquare, faSyncAlt, faTrashAlt, faUser, faUserCog, faUsers, faWallet ) export default FontAwesomeIcon diff --git a/src/resources/sass/_variables.scss b/src/resources/sass/_variables.scss index 35dc03c3..66d55c8c 100644 --- a/src/resources/sass/_variables.scss +++ b/src/resources/sass/_variables.scss @@ -1,22 +1,23 @@ // Body $body-bg: #fff; // Typography $font-family-sans-serif: 'Nunito', sans-serif; $font-size-base: 0.9rem; $line-height-base: 1.6; // Colors $blue: #3490dc; $indigo: #6574cd; $purple: #9561e2; $pink: #f66d9b; $red: #e3342f; $orange: #f6993f; $yellow: #ffed4a; $green: #38c172; $teal: #4dc0b5; $cyan: #6cb2eb; // App colors $menu-bg-color: #f6f5f3; +$main-color: #f1a539; diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss index 0b1bfe77..35b5d2e2 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,217 +1,200 @@ // Fonts // Variables @import 'variables'; // Bootstrap @import '~bootstrap/scss/bootstrap'; -// Toastr -@import '~@deveodk/vue-toastr/dist/@deveodk/vue-toastr.css'; - -// Fixes Toastr incompatibility with Bootstrap -.toast-container > .toast { - opacity: 1; -} - @import 'menu'; +@import 'toast'; +@import 'forms'; -nav + .container { - margin-top: 120px; +html, +body, +body > .outer-container { + height: 100%; } #app { - margin-bottom: 2rem; + display: flex; + flex-direction: column; + min-height: 100%; + + & > nav { + flex-shrink: 0; + z-index: 1; + } + + & > div.container { + flex-grow: 1; + margin-top: 2rem; + margin-bottom: 2rem; + } + + & > .filler { + flex-grow: 1; + } + + & > div.container + .filler { + display: none; + } } #error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } -.list-input { - & > div { - &:not(:last-child) { - margin-bottom: -1px; - - input, - a.btn { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; - } - } - - &:not(:first-child) { - input, - a.btn { - border-top-right-radius: 0; - border-top-left-radius: 0; - } - } - } - - input.is-invalid { - z-index: 2; - } -} - -.range-input { - display: flex; - - label { - margin-right: 0.5em; - } -} - tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; height: 8em; td { vertical-align: middle; } tbody:not(:empty) + & { display: none; } } table { td.buttons, td.price, td.selection { width: 1%; } td.price { text-align: right; } &.form-list { td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } } } ul.status-list { list-style: none; padding: 0; margin: 0; svg { width: 1.25rem !important; height: 1.25rem; } span { vertical-align: top; } } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; margin-top: 1rem; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0 0.5rem 0.5rem 0; text-decoration: none; min-width: 8rem; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; } } .plan-selector { .plan-ico { font-size: 4em; color: #f1a539; border: 3px solid #f1a539; width: 100px; margin-bottom: 1rem; border-radius: 50%; } ul:last-child { margin-bottom: 0; } } diff --git a/src/resources/sass/forms.scss b/src/resources/sass/forms.scss new file mode 100644 index 00000000..f09e9ceb --- /dev/null +++ b/src/resources/sass/forms.scss @@ -0,0 +1,34 @@ + +.list-input { + & > div { + &:not(:last-child) { + margin-bottom: -1px; + + input, + a.btn { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + } + + &:not(:first-child) { + input, + a.btn { + border-top-right-radius: 0; + border-top-left-radius: 0; + } + } + } + + input.is-invalid { + z-index: 2; + } +} + +.range-input { + display: flex; + + label { + margin-right: 0.5em; + } +} diff --git a/src/resources/sass/menu.scss b/src/resources/sass/menu.scss index 32ff4b5d..70e3c0f4 100644 --- a/src/resources/sass/menu.scss +++ b/src/resources/sass/menu.scss @@ -1,85 +1,102 @@ -#primary-menu { - background-color: #f6f5f3; +#header-menu { + background-color: $menu-bg-color; padding: 0; line-height: 85px; .navbar-brand { padding: 0; outline: 0; > img { display: inline; vertical-align: middle; } } .nav-link { color: #202020; line-height: 85px; padding: 0 0 0 25px; background: transparent; &:focus { text-decoration: underline; outline: 0; } &:hover { - color: #f1a539; + color: $main-color; text-decoration: underline; } &.active:not(.menulogin) { font-weight: bold; } } } +#footer-menu { + background-color: $main-color; + height: 100px; + + .navbar-brand { + img { + width: 170px; + } + } +} + @include media-breakpoint-up(lg) { - #primary-menu { + #header-menu { a.menulogin { text-transform: uppercase; - border: 2px solid #f1a539; + border: 2px solid $main-color; border-radius: 21px; line-height: 21px; letter-spacing: 1px; padding: 6px 34px; margin: 25px 0 25px 25px; &:focus, &:hover { text-decoration: none; - background-color: #f1a539; + background-color: $main-color; color: #fff; font-weight: normal; } } } + + .navbar { + .navbar { + justify-content: flex-end; + } + } } @include media-breakpoint-down(md) { - #primary-menu { + #header-menu { .navbar-nav { padding-bottom: 1em; } .nav-link { line-height: 45px; padding: 0; } } } @include media-breakpoint-down(sm) { - #primary-menu { + #header-menu { padding: 0 1em; } } @media (max-width: 340px) { - #primary-menu { + #header-menu { .navbar-brand img { width: 160px; } } } diff --git a/src/resources/sass/toast.scss b/src/resources/sass/toast.scss new file mode 100644 index 00000000..0278a34f --- /dev/null +++ b/src/resources/sass/toast.scss @@ -0,0 +1,46 @@ +.toast-container { + position: fixed; + bottom: 0; + right: 0; + margin: 0.5rem; + width: 320px; + z-index: 10; +} + +.toast { + background-color: rgba(52, 58, 64, 0.95); + + &:not(:last-child) { + margin-bottom: 0.3rem; + } +} + +.toast-header { + background-color: #343a40; + border-color: #555; + color: #fff; + + strong { + flex: 1; + } + + svg { + font-size: 1.2em; + margin-right: 0.5rem; + } + + button.close { + color: #eee; + opacity: 1 !important; + text-shadow: none; + font-size: 1.2rem; + + &:hover { + color: #fff; + } + } +} + +.toast-body { + color: #fff; +} diff --git a/src/resources/views/root.blade.php b/src/resources/views/root.blade.php index b9b275ad..c39abefb 100644 --- a/src/resources/views/root.blade.php +++ b/src/resources/views/root.blade.php @@ -1,8 +1,10 @@ @extends('layouts.app') @section('title', "Home") @section('content')
- + +
+
@endsection diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue index c8db10c2..f1616a88 100644 --- a/src/resources/vue/Admin/Dashboard.vue +++ b/src/resources/vue/Admin/Dashboard.vue @@ -1,83 +1,83 @@ diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index b6fd1423..f5e09e16 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,404 +1,404 @@ diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index a7630654..d5d356e5 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,117 +1,117 @@ diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index 00014a78..f4120c68 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,80 +1,80 @@ diff --git a/src/resources/vue/Logout.vue b/src/resources/vue/Logout.vue index 7a02171f..b257b1c8 100644 --- a/src/resources/vue/Logout.vue +++ b/src/resources/vue/Logout.vue @@ -1,14 +1,14 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index 4692117c..2c9e2ab8 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,350 +1,350 @@ diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue index f52191d4..1af291ed 100644 --- a/src/resources/vue/User/List.vue +++ b/src/resources/vue/User/List.vue @@ -1,124 +1,124 @@ diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue index f91f9f9f..a03d052e 100644 --- a/src/resources/vue/User/Profile.vue +++ b/src/resources/vue/User/Profile.vue @@ -1,108 +1,108 @@ diff --git a/src/resources/vue/User/ProfileDelete.vue b/src/resources/vue/User/ProfileDelete.vue index 27ebf1b3..6c1174dc 100644 --- a/src/resources/vue/User/ProfileDelete.vue +++ b/src/resources/vue/User/ProfileDelete.vue @@ -1,49 +1,49 @@ diff --git a/src/resources/vue/Widgets/ListInput.vue b/src/resources/vue/Widgets/ListInput.vue index b38a0f05..61432efb 100644 --- a/src/resources/vue/Widgets/ListInput.vue +++ b/src/resources/vue/Widgets/ListInput.vue @@ -1,55 +1,55 @@ diff --git a/src/resources/vue/Menu.vue b/src/resources/vue/Widgets/Menu.vue similarity index 67% rename from src/resources/vue/Menu.vue rename to src/resources/vue/Widgets/Menu.vue index 56cc648a..c4d943cc 100644 --- a/src/resources/vue/Menu.vue +++ b/src/resources/vue/Widgets/Menu.vue @@ -1,62 +1,74 @@ diff --git a/src/resources/vue/Widgets/Toast.vue b/src/resources/vue/Widgets/Toast.vue new file mode 100644 index 00000000..35351cf4 --- /dev/null +++ b/src/resources/vue/Widgets/Toast.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/resources/vue/Widgets/ToastMessage.vue b/src/resources/vue/Widgets/ToastMessage.vue new file mode 100644 index 00000000..9037ed7c --- /dev/null +++ b/src/resources/vue/Widgets/ToastMessage.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 50c2531e..6429052d 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,206 +1,207 @@ 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 $count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { - $browser->assertSeeIn('div.tooltip .tooltip-inner', $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($type, $title, $message) + 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) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename)) { sleep(2); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); 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/Admin/DashboardTest.php b/src/tests/Browser/Admin/DashboardTest.php index 07f195e2..52f76689 100644 --- a/src/tests/Browser/Admin/DashboardTest.php +++ b/src/tests/Browser/Admin/DashboardTest.php @@ -1,77 +1,77 @@ getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); } /** * {@inheritDoc} */ public function tearDown(): void { $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); parent::tearDown(); } /** * Test user search */ public function testSearch(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) ->on(new Dashboard()) ->assertFocused('@search input') ->assertMissing('@search table'); // Test search with no results $browser->type('@search input', 'unknown') ->click('@search form button') - ->assertToast(Toast::TYPE_INFO, '', '0 user accounts have been found.') + ->assertToast(Toast::TYPE_INFO, '0 user accounts have been found.') ->assertMissing('@search table'); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); // Test search with multiple results $browser->type('@search input', 'john.doe.external@gmail.com') ->click('@search form button') - ->assertToast(Toast::TYPE_INFO, '', '2 user accounts have been found.') + ->assertToast(Toast::TYPE_INFO, '2 user accounts have been found.') ->whenAvailable('@search table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2); // TODO: Assert table content }); // Test search with single record result -> redirect to user page $browser->type('@search input', 'kolab.org') ->click('@search form button') ->assertMissing('@search table') ->waitForLocation('/user/' . $john->id) ->waitFor('#user-info') ->assertVisible('#user-info .card-title', $john->email); }); } } diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php index a407d089..e6055a5d 100644 --- a/src/tests/Browser/Admin/DomainTest.php +++ b/src/tests/Browser/Admin/DomainTest.php @@ -1,89 +1,89 @@ browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $john = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($john->id); // Goto the domain page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) - ->pause(500) ->click('@nav #tab-domains') + ->pause(1000) ->click('@user-domains table tbody tr:first-child td a'); $browser->on($domain_page) ->assertSeeIn('@domain-info .card-title', 'kolab.org') ->with('@domain-info form', function (Browser $browser) use ($domain) { $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 1); // Assert Configuration tab $browser->assertSeeIn('@nav #tab-config', 'Configuration') ->with('@domain-config', function (Browser $browser) { $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') ->assertSeeIn('pre#dns-config', 'kolab.org.'); }); }); } } diff --git a/src/tests/Browser/Admin/LogonTest.php b/src/tests/Browser/Admin/LogonTest.php index 09cc7b22..b221169d 100644 --- a/src/tests/Browser/Admin/LogonTest.php +++ b/src/tests/Browser/Admin/LogonTest.php @@ -1,159 +1,145 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->with(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }) ->assertMissing('@second-factor-input') ->assertMissing('@forgot-password'); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) - ->submitLogon('jeroen@jeroen.jeroen', 'wrong'); - - // Error message - $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { - $browser->assertToastTitle('Error') - ->assertToastMessage('Invalid username or password.') - ->closeToast(); - }); - - // Checks if we're still on the logon page - $browser->on(new Home()); + ->submitLogon('jeroen@jeroen.jeroen', 'wrong') + // Error message + ->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.') + // Checks if we're still on the logon page + ->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }) ->assertUser('jeroen@jeroen.jeroen'); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->click('.link-logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message - $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('Successfully logged out') - ->closeToast(); - }); + $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message - $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('Successfully logged out') - ->closeToast(); - }); + $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } } diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php index d0b27ecb..f890db73 100644 --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -1,402 +1,402 @@ getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => '+48123123123', ]); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->balance = 0; $wallet->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => null, ]); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->balance = 0; $wallet->save(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testUserUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $browser->visit('/user/' . $jack->id)->on(new Home()); }); } /** * Test user info page */ public function testUserInfo(): void { $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $page = new UserPage($jack->id); $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) ->on(new Dashboard()) ->visit($page) ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $jack->email) ->with('@user-info form', function (Browser $browser) use ($jack) { $browser->assertElementsCount('.row', 7) ->assertSeeIn('.row:nth-child(1) label', 'Managed by') ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') ->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(4) label', 'First name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->assertSeeIn('.row:nth-child(6) label', 'External email') ->assertMissing('.row:nth-child(6) #external_email a') ->assertSeeIn('.row:nth-child(7) label', 'Country') ->assertSeeIn('.row:nth-child(7) #country', 'United States of America'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 5); // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { $browser->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-success', '0,00 CHF') ->with('form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); }); }); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF') ->assertMissing('table tfoot'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); }); } /** * Test user info page (continue) * * @depends testUserInfo */ public function testUserInfo2(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $page = new UserPage($john->id); $discount = Discount::where('code', 'TEST')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->debit(2010); $wallet->save(); // Click the managed-by link on Jack's page $browser->click('@user-info #manager a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $john->email) ->with('@user-info form', function (Browser $browser) use ($john) { $ext_email = $john->getSetting('external_email'); $browser->assertElementsCount('.row', 8) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'First name') ->assertSeeIn('.row:nth-child(3) #first_name', 'John') ->assertSeeIn('.row:nth-child(4) label', 'Last name') ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') ->assertSeeIn('.row:nth-child(5) label', 'Phone') ->assertSeeIn('.row:nth-child(5) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(6) label', 'External email') ->assertSeeIn('.row:nth-child(6) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(6) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(7) label', 'Address') ->assertSeeIn('.row:nth-child(7) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(8) label', 'Country') ->assertSeeIn('.row:nth-child(8) #country', 'United States of America'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 5); // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { $browser->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-danger', '-20,10 CHF') ->with('form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); }); }); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') ->with('@user-domains table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (3)') ->click('@nav #tab-users') ->with('@user-users table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 3) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(3) td:first-child a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') ->assertMissing('tfoot'); }); }); // Now we go to Ned's info page, he's a controller on John's wallet $this->browse(function (Browser $browser) { $ned = $this->getTestUser('ned@kolab.org'); $page = new UserPage($ned->id); $browser->click('@user-users tbody tr:nth-child(3) td:first-child a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $ned->email) ->with('@user-info form', function (Browser $browser) use ($ned) { $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 5); // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { $browser->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-success', '0,00 CHF') ->with('form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); }); }); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); }); // Assert Subscriptions tab, we expect John's discount here $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 5) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // We don't expect John's domains here $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // We don't expect John's users here $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); }); } /** * Test editing wallet discount * * @depends testUserInfo2 */ public function testWalletDiscount(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->pause(100) ->click('@user-finances #discount button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#discount-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Account discount') ->assertFocused('@body select') ->assertSelected('@body select', '') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') ->click('@button-cancel'); }) ->assertMissing('#discount-dialog') ->click('@user-finances #discount button') // Change the discount ->with(new Dialog('#discount-dialog'), function (Browser $browser) { $browser->click('@body select') ->click('@body select option:nth-child(2)') ->click('@button-action'); }) - ->assertToast(Toast::TYPE_SUCCESS, '', 'User wallet updated successfully.') + ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.') ->assertSeeIn('#discount span', '10% - Test voucher') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }) // Change back to 'none' ->click('@nav #tab-finances') ->click('@user-finances #discount button') ->with(new Dialog('#discount-dialog'), function (Browser $browser) { $browser->click('@body select') ->click('@body select option:nth-child(1)') ->click('@button-action'); }) - ->assertToast(Toast::TYPE_SUCCESS, '', 'User wallet updated successfully.') + ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.') ->assertSeeIn('#discount span', 'none') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF/month') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF/month') ->assertMissing('table + .hint'); }); }); } } diff --git a/src/tests/Browser/Components/Menu.php b/src/tests/Browser/Components/Menu.php index 1b6624ba..e19139a4 100644 --- a/src/tests/Browser/Components/Menu.php +++ b/src/tests/Browser/Components/Menu.php @@ -1,83 +1,94 @@ mode = $mode; + } + /** * Get the root selector for the component. * * @return string */ public function selector() { - return '#primary-menu'; + return '#' . $this->mode . '-menu'; } /** * Assert that the browser page contains the component. * * @param \Laravel\Dusk\Browser $browser * * @return void */ public function assert($browser) { $browser->assertVisible($this->selector()); - $browser->assertVisible('@brand'); } /** * Assert that menu contains only specified menu items. * * @param \Laravel\Dusk\Browser $browser * @param array $items List of menu items * * @return void */ public function assertMenuItems($browser, array $items) { // TODO: On mobile the links will not be visible foreach ($items as $item) { $browser->assertVisible('.link-' . $item); } // Check number of items, to make sure there's no extra items PHPUnit::assertCount(count($items), $browser->elements('li')); } /** * Assert that specified menu item is active * * @param \Laravel\Dusk\Browser $browser * @param string $item Menu item name * * @return void */ public function assertActiveItem($browser, string $item) { // TODO: On mobile the links will not be visible $browser->assertVisible(".link-{$item}.active"); } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { $selector = $this->selector(); return [ - '@list' => "$selector .navbar-nav", - '@brand' => "$selector .navbar-brand", - '@toggler' => "$selector .navbar-toggler", + '@list' => ".navbar-nav", + '@brand' => ".navbar-brand", + '@toggler' => ".navbar-toggler", ]; } } diff --git a/src/tests/Browser/Components/Toast.php b/src/tests/Browser/Components/Toast.php index 30dacba2..1e4409d1 100644 --- a/src/tests/Browser/Components/Toast.php +++ b/src/tests/Browser/Components/Toast.php @@ -1,87 +1,101 @@ type = $type; } /** * Get the root selector for the component. * * @return string */ public function selector() { return '.toast-container > .toast.toast-' . $this->type; } /** * Assert that the browser page contains the component. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser) { $browser->waitFor($this->selector()); $this->element = $browser->element($this->selector()); } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { return [ - '@title' => ".toast-title", - '@message' => ".toast-message", + '@title' => ".toast-header > strong", + '@message' => ".toast-body", ]; } /** * Assert title of the toast element */ - public function assertToastTitle($browser, string $title) + public function assertToastTitle($browser, string $title = null) { if (empty($title)) { - $browser->assertMissing('@title'); - } else { - $browser->assertSeeIn('@title', $title); + switch ($this->type) { + case self::TYPE_ERROR: + $title = 'Error'; + break; + case self::TYPE_SUCCESS: + $title = 'Success'; + break; + case self::TYPE_WARNING: + $title = 'Warning'; + break; + case self::TYPE_INFO: + $title = 'Information'; + break; + } } + + $browser->assertSeeIn('@title', $title); } /** * Assert message of the toast element */ public function assertToastMessage($browser, string $message) { $browser->assertSeeIn('@message', $message); } /** * Close the toast with a click */ public function closeToast($browser) { - $this->element->click(); + $this->element->findElements(WebDriverBy::cssSelector('button.close'))[0]->click(); } } diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php index 11585d02..64e67b08 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,135 +1,131 @@ browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertVisible('@status') ->whenAvailable('@verify', function ($browser) use ($domain) { // Make sure the domain is confirmed now // TODO: Test verification process failure $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button'); }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) ->assertMissing('@verify') - ->with(new Toast(Toast::TYPE_SUCCESS), function ($browser) { - $browser->assertToastTitle('') - ->assertToastMessage('Domain verified successfully') - ->closeToast(); - }); + ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.'); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domains list page (unauthenticated) */ public function testDomainListUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/logout') ->visit('/domains') ->on(new Home()); }); } /** * Test domains list page * * @depends testDomainListUnauth */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); // TODO: Test domains list acting as Ned (John's "delegatee") } } diff --git a/src/tests/Browser/ErrorTest.php b/src/tests/Browser/ErrorTest.php index 91fa83bb..aaeec604 100644 --- a/src/tests/Browser/ErrorTest.php +++ b/src/tests/Browser/ErrorTest.php @@ -1,36 +1,38 @@ browse(function (Browser $browser) { - $browser->visit('/unknown'); + $browser->visit('/unknown') + ->waitFor('#app > #error-page') + ->assertVisible('#app > #header-menu') + ->assertVisible('#app > #footer-menu'); - $browser->waitFor('#app > #error-page'); - $browser->assertVisible('#app > #primary-menu'); $this->assertSame('404', $browser->text('#error-page .code')); $this->assertSame('Not Found', $browser->text('#error-page .message')); }); $this->browse(function (Browser $browser) { - $browser->visit('/login/unknown'); + $browser->visit('/login/unknown') + ->waitFor('#app > #error-page') + ->assertVisible('#app > #header-menu') + ->assertVisible('#app > #footer-menu'); - $browser->waitFor('#app > #error-page'); - $browser->assertVisible('#app > #primary-menu'); $this->assertSame('404', $browser->text('#error-page .code')); $this->assertSame('Not Found', $browser->text('#error-page .message')); }); } } diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index e008f2d9..89fa7d0a 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,202 +1,196 @@ browse(function (Browser $browser) { - $browser->visit(new Home()); - $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); - }); + $browser->visit(new Home()) + ->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + }) + ->within(new Menu('footer'), function ($browser) { + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'webmail']); + }); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Error message - $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { - $browser->assertToastTitle('Error') - ->assertToastMessage('Invalid username or password.') - ->closeToast(); - }); + $browser->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.'); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }) + ->within(new Menu('footer'), function ($browser) { + $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); + }) ->assertUser('john@kolab.org'); // Assert no "Account status" for this account $browser->assertMissing('@status'); // Goto /domains and assert that the link on logo element // leads to the dashboard $browser->visit('/domains') ->waitForText('Domains') ->click('a.navbar-brand') ->on(new Dashboard()); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->click('.link-logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message - $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('Successfully logged out') - ->closeToast(); - }); + $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message - $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('Successfully logged out') - ->closeToast(); - }); + $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Test 2-Factor Authentication * * @depends testLogoutByURL */ public function test2FA(): void { $this->browse(function (Browser $browser) { // Test missing 2fa code $browser->on(new Home()) ->type('@email-input', 'ned@kolab.org') ->type('@password-input', 'simple123') ->press('form button') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is required.' ) ->assertFocused('@second-factor-input') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // Test invalid code $browser->type('@second-factor-input', '123456') ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is invalid.' ) ->assertFocused('@second-factor-input') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $code = \App\Auth\SecondFactor::code('ned@kolab.org'); // Test valid (TOTP) code $browser->type('@second-factor-input', $code) ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitForLocation('/dashboard')->on(new Dashboard()); }); } } diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index d90f0d95..464098c6 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,526 +1,526 @@ deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); } public function tearDown(): void { $this->deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); parent::tearDown(); } /** * Test signup code verification with a link */ public function testSignupCodeByLink(): void { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'data' => [ 'email' => 'User@example.org', 'name' => 'User Name', 'plan' => 'individual', 'voucher' => '', ] ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code) ->waitFor('@step3') ->assertMissing('@step1') ->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** * Test signup "welcome" page */ public function testSignupStep0(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); $browser->assertActiveItem('signup'); }); $browser->waitFor('@step0 .plan-selector > .plan-box'); // Assert first plan box and press the button $browser->with('@step0 .plan-selector > .plan-individual', function ($step) { $step->assertVisible('button') ->assertSeeIn('button', 'Individual Account') ->assertVisible('.plan-description') ->click('button'); }); $browser->waitForLocation('/signup/individual') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_name'); // Click Back button $browser->click('@step1 [type=button]') ->waitForLocation('/signup') ->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); // Choose the group account plan $browser->click('@step0 .plan-selector > .plan-group button') ->waitForLocation('/signup/group') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_name'); // TODO: Test if 'plan' variable is set properly in vue component }); } /** * Test 1st step of the signup process */ public function testSignupStep1(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/individual')->onWithoutAssert(new Signup()); $browser->assertVisible('@step1'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); $browser->assertActiveItem('signup'); }); // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { $step->assertVisible('#signup_name') ->assertFocused('#signup_name') ->assertVisible('#signup_email') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Submit empty form // Both Step 1 inputs are required, so after pressing Submit // we expect focus to be moved to the first input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_name'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#signup_name', 'Test User') ->type('#signup_email', '@test') ->click('[type=submit]') ->waitFor('#signup_email.is-invalid') ->assertVisible('#signup_email + .invalid-feedback') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); $step->assertMissing('#signup_email.is-invalid'); $step->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 */ public function testSignupStep2(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2') ->assertMissing('@step0') ->assertMissing('@step1') ->assertMissing('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code') ->assertFocused('#signup_short_code') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]') ->waitFor('@step1') ->assertFocused('@step1 #signup_name') ->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $step->waitFor('#signup_short_code.is-invalid') ->assertVisible('#signup_short_code + .invalid-feedback') ->assertFocused('#signup_short_code') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 */ public function testSignupStep3(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 3 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#signup_login'); $step->assertVisible('#signup_password'); $step->assertVisible('#signup_confirm'); $step->assertVisible('select#signup_domain'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#signup_login'); $step->assertValue('select#signup_domain', \config('app.domain')); $step->assertValue('#signup_login', ''); $step->assertValue('#signup_password', ''); $step->assertValue('#signup_confirm', ''); // TODO: Test domain selector }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_password', '12345678') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertFocused('#signup_login') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) use ($browser) { $step->type('#signup_login', 'SignupTestDusk') ->click('[type=submit]') ->waitFor('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertMissing('#signup_login.is-invalid') ->assertMissing('#signup_domain + .invalid-feedback') ->assertFocused('#signup_password') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_confirm', '12345678'); $step->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')); // Logout the user $browser->click('a.link-logout'); }); } /** * Test signup for a group account */ public function testSignupGroup(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); // Choose the group account plan $browser->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button'); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->whenAvailable('@step1', function ($step) { $step->type('#signup_name', 'Test User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); // Submit valid code $browser->whenAvailable('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }); // Here we expect 4 text inputs, Back and Continue buttons $browser->whenAvailable('@step3', function ($step) { $step->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_confirm') ->assertVisible('input#signup_domain') ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertFocused('#signup_login') ->assertValue('input#signup_domain', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_confirm', ''); }); // Submit invalid login and password data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_domain', 'test.com') ->type('#signup_password', '12345678') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertFocused('#signup_login') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) use ($browser) { $step->type('#signup_login', 'admin') ->type('#signup_domain', 'aaa') ->type('#signup_password', '12345678') ->type('#signup_confirm', '12345678') ->click('[type=submit]') ->waitUntilMissing('#signup_login.is-invalid') - ->assertVisible('#signup_domain.is-invalid + .invalid-feedback') + ->waitFor('#signup_domain.is-invalid + .invalid-feedback') ->assertMissing('#signup_password.is-invalid') ->assertMissing('#signup_password + .invalid-feedback') ->assertFocused('#signup_domain') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_domain', 'user-domain-signup.com') ->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('admin@user-domain-signup.com'); $browser->click('a.link-logout'); }); } /** * Test signup with voucher */ public function testSignupVoucherLink(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/voucher/TEST') ->onWithoutAssert(new Signup()) ->waitFor('@step0') ->click('.plan-individual button') ->whenAvailable('@step1', function (Browser $browser) { $browser->type('#signup_name', 'Test User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }) ->whenAvailable('@step2', function (Browser $browser) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $browser->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $browser->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }) ->whenAvailable('@step3', function (Browser $browser) { // Assert that the code is filled in the input // Change it and test error handling $browser->assertValue('#signup_voucher', 'TEST') ->type('#signup_voucher', 'TESTXX') ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '123456789') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_voucher.is-invalid') ->assertVisible('#signup_voucher + .invalid-feedback') ->assertFocused('#signup_voucher') - ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Submit the correct code ->type('#signup_voucher', 'TEST') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) // Logout the user ->click('a.link-logout'); }); $user = $this->getTestUser('signuptestdusk@' . \config('app.domain')); $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); } } diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php index 2b44ee02..b4693387 100644 --- a/src/tests/Browser/UserProfileTest.php +++ b/src/tests/Browser/UserProfileTest.php @@ -1,199 +1,187 @@ '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', ]; /** * {@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', 'Phone') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['phone']) ->assertSeeIn('div.row:nth-child(4) label', 'External email') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['external_email']) ->assertSeeIn('div.row:nth-child(5) label', 'Address') ->assertValue('div.row:nth-child(5) textarea', $this->profile['billing_address']) ->assertSeeIn('div.row:nth-child(6) label', 'Country') ->assertValue('div.row:nth-child(6) select', $this->profile['country']) ->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 all fields and submit // FIXME: Should any of these fields be required? $browser->type('#first_name', '') ->type('#last_name', '') ->type('#phone', '') ->type('#external_email', '') ->type('#billing_address', '') ->select('#country', '') ->click('button[type=submit]'); }) - ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('User data updated successfully') - ->closeToast(); - }); + ->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'); - }) - ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { - $browser->assertToastTitle('Error') - ->assertToastMessage('Form validation error') - ->closeToast(); + ->assertFocused('#phone') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); }); } /** * 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') - ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('User deleted successfully.') - ->closeToast(); - }); + ->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 761b078c..bb891ce5 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,589 +1,549 @@ 'John', 'last_name' => 'Doe', ]; /** * {@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->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'); }); }); } /** * 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', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', 'john@kolab.org') ->assertDisabled('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(['john.doe@kolab.org']) ->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('button[type=submit]', 'Submit'); // Clear some fields and submit $browser->type('#first_name', '') ->type('#last_name', '') ->click('button[type=submit]'); }) - ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('User data updated successfully') - ->closeToast(); - }); + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); // 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'); - }) - ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { - $browser->assertToastTitle('Error') - ->assertToastMessage('Form validation error') - ->closeToast(); + ->assertFocused('#password') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // TODO: Test password change // 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]'); - }) - ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { - $browser->assertToastTitle('Error') - ->assertToastMessage('Form validation error') - ->closeToast(); + ->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); }); }); // 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'); }) - ->click('button[type=submit]'); - }) - ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('User data updated successfully') - ->closeToast(); + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }); $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(8) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(8)') ->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]'); - }) - ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('User data updated successfully') - ->closeToast(); + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }); $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', 'Email') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertEnabled('div.row:nth-child(3) input[type=text]') ->assertSeeIn('div.row:nth-child(4) label', 'Email aliases') ->assertVisible('div.row:nth-child(4) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(5) label', 'Password') ->assertValue('div.row:nth-child(5) input[type=password]', '') ->assertSeeIn('div.row:nth-child(6) label', 'Confirm password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) 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]'); - }) - ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { - $browser->assertToastTitle('Error') - ->assertToastMessage('Form validation error') - ->closeToast(); - }) - ->with('@form', function (Browser $browser) { - $browser->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') + ->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]'); - }) - ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { - $browser->assertToastTitle('Error') - ->assertToastMessage('Form validation error') - ->closeToast(); - }) - ->with('@form', function (Browser $browser) { - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->assertFormError(1, 'The specified alias is invalid.', false); - }); + ->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->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) - ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('User created successfully') - ->closeToast(); - }) + ->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']); }); } /** * 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'); }) - ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { - $browser->assertToastTitle('') - ->assertToastMessage('User deleted successfully') - ->closeToast(); - }) + ->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); }); }); // 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()) ->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'); }); }); } } diff --git a/src/tests/TestCaseDusk.php b/src/tests/TestCaseDusk.php index 1442cf55..92882119 100644 --- a/src/tests/TestCaseDusk.php +++ b/src/tests/TestCaseDusk.php @@ -1,98 +1,98 @@ addArguments([ '--lang=en_US', '--disable-gpu', '--headless', ]); // For file download handling $prefs = [ 'profile.default_content_settings.popups' => 0, 'download.default_directory' => __DIR__ . '/Browser/downloads', ]; $options->setExperimentalOption('prefs', $prefs); if (getenv('TESTS_MODE') == 'phone') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/537.36' . ' (KHTML, like Gecko) Chrome/60.0.3112.90 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=375,667']); } elseif (getenv('TESTS_MODE') == 'tablet') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 ' . ' (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=800,640']); } else { - $options->addArguments(['--window-size=2560,1440']); + $options->addArguments(['--window-size=1280,1024']); } // Make sure downloads dir exists and is empty if (!file_exists(__DIR__ . '/Browser/downloads')) { mkdir(__DIR__ . '/Browser/downloads', 0777, true); } else { foreach (glob(__DIR__ . '/Browser/downloads/*') as $file) { @unlink($file); } } return RemoteWebDriver::create( 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options ) ); } /** * Replace Dusk's Browser with our (extended) Browser */ protected function newBrowser($driver) { return new Browser($driver); } /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set baseURL for all tests in this file // If we wanted to visit both user and admin in one test // we can also just call visit() with full url Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url')); } }