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($('
'))
}
},
// 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 = ``
$('#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 @@
Primary Email
ID
{{ user.email }}
{{ user.id }}
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 @@
Account balance {{ $root.price(balance) }}
Email address
{{ alias }}
This user has no email aliases.
Subscription
Price
{{ sku.name }}
{{ sku.price }}
This user has no subscriptions.
¹ applied discount: {{ discount }}% - {{ discount_description }}
Name
{{ domain.namespace }}
There are no domains in this account.
Primary Email
{{ item.email }}
There are no users in this account.
- none -
{{ item.label }}
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 @@
Domain status: Not ready
The process to create the domain has not been completed yet.
Some features may be disabled or readonly.
Domain verification
In order to confirm that you're the actual holder of the domain,
we need to run a verification process before finally activating it for email delivery.
The domain must have one of the following entries in DNS:
TXT entry with value: {{ domain.hash_text }}
or CNAME entry: {{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.
When this is done press the button below to start the verification.
Here's a sample zone file for your domain:
{{ domain.dns.join("\n") }}
Verify
Domain configuration
In order to let {{ app_name }} receive email traffic for your domain you need to adjust
the DNS settings, more precisely the MX entries, accordingly.
Edit your domain's zone file and replace existing MX
entries with the following values:
{{ domain.config.join("\n") }}
If you don't know how to set DNS entries for your domain,
please contact the registration service where you registered
the domain or your web hosting provider.
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 @@
User account
New user account
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 @@
User Accounts
Create user
Primary Email
{{ user.email }}
Delete
Do you really want to delete this user permanently?
This will delete all account data and withdraw the permission to access the email account.
Please note that this action cannot be undone.
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 @@
Delete this account?
This will delete the account as well as all domains, users and aliases associated with this account.
This operation is irreversible .
As you will not be able to recover anything after this point, please make sure
that you have migrated all data before proceeding.
As we always strive to improve, we would like to ask for 2 minutes of your time.
The best tool for improvement is feedback from users, and we would like to ask
for a few words about your reasons for leaving our service. Please send your feedback
to support@kolabnow.com.
Also feel free to contact Kolab Now Support at support@kolabnow.com with any questions
or concerns that you may have in this context.
Cancel
Delete account
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'));
}
}