diff --git a/src/app/Wallet.php b/src/app/Wallet.php
index 545d40f4..53d47bed 100644
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -1,187 +1,187 @@
0.00,
+ 'balance' => 0,
'currency' => 'CHF'
];
protected $fillable = [
'currency'
];
protected $nullable = [
'description'
];
protected $casts = [
- 'balance' => 'float',
+ 'balance' => 'integer',
];
protected $guarded = ['balance'];
/**
* Add a controller to this wallet.
*
* @param \App\User $user The user to add as a controller to this wallet.
*
* @return void
*/
public function addController(User $user)
{
if (!$this->controllers->contains($user)) {
$this->controllers()->save($user);
}
}
public function chargeEntitlements($apply = true)
{
$charges = 0;
foreach ($this->entitlements()->get()->fresh() as $entitlement) {
// This entitlement has been created less than or equal to 14 days ago (this is at
// maximum the fourteenth 24-hour period).
if ($entitlement->created_at > Carbon::now()->subDays(14)) {
continue;
}
// This entitlement was created, or billed last, less than a month ago.
if ($entitlement->updated_at > Carbon::now()->subMonths(1)) {
continue;
}
// created more than a month ago -- was it billed?
if ($entitlement->updated_at <= Carbon::now()->subMonths(1)) {
$diff = $entitlement->updated_at->diffInMonths(Carbon::now());
$charges += $entitlement->cost * $diff;
// if we're in dry-run, you know...
if (!$apply) {
continue;
}
$entitlement->updated_at = $entitlement->updated_at->copy()->addMonths($diff);
$entitlement->save();
$this->debit($entitlement->cost * $diff);
}
}
return $charges;
}
/**
* Calculate the expected charges to this wallet.
*
* @return int
*/
public function expectedCharges()
{
return $this->chargeEntitlements(false);
}
/**
* Remove a controller from this wallet.
*
* @param \App\User $user The user to remove as a controller from this wallet.
*
* @return void
*/
public function removeController(User $user)
{
if ($this->controllers->contains($user)) {
$this->controllers()->detach($user);
}
}
/**
* Add an amount of pecunia to this wallet's balance.
*
* @param float $amount The amount of pecunia to add.
*
* @return Wallet
*/
public function credit(float $amount)
{
$this->balance += $amount;
$this->save();
return $this;
}
/**
* Deduct an amount of pecunia from this wallet's balance.
*
* @param float $amount The amount of pecunia to deduct.
*
* @return Wallet
*/
public function debit(float $amount)
{
$this->balance -= $amount;
$this->save();
return $this;
}
/**
* Controllers of this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function controllers()
{
return $this->belongsToMany(
'App\User', // The foreign object definition
'user_accounts', // The table name
'wallet_id', // The local foreign key
'user_id' // The remote foreign key
);
}
/**
* Entitlements billed to this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany('App\Entitlement');
}
/**
* The owner of the wallet -- the wallet is in his/her back pocket.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function owner()
{
return $this->belongsTo('App\User', 'user_id', 'id');
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 08195b8d..37bce4b6 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,226 +1,226 @@
/**
* 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 router from './routes'
import store from './store'
import FontAwesomeIcon from './fontawesome'
import VueToastr from '@deveodk/vue-toastr'
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')
}
})
// Add a response interceptor for general/validation error handler
// This have to be before Vue and Router setup. Otherwise we would
// not be able to handle axios responses initiated from inside
// components created/mounted handlers (e.g. signup code verification link)
window.axios.interceptors.response.use(
response => {
// Do nothing
return response
},
error => {
var error_msg
if (error.response && 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('.listinput')) {
// List input widget
let list = input.next('.listinput-widget')
list.children(':not(:first-child)').each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
list.addClass('is-invalid').next('.invalid-feedback').remove()
list.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')
// Pass the error as-is
return Promise.reject(error)
}
)
const app = new Vue({
el: '#app',
components: {
'app-component': AppComponent,
'menu-component': MenuComponent
},
store,
router,
data() {
return {
isLoading: true
}
},
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) {
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
router.push({ name: 'login' })
},
// Display "loading" overlay (to be used by route components)
startLoading() {
this.isLoading = true
// Lock the UI with the 'loading...' element
$('#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.status === 401) {
this.logoutUser()
} else {
this.errorPage(error.response.status, error.response.statusText)
}
},
price(price) {
- return (price/100).toLocaleString('de-CH', { style: 'currency', currency: 'CHF' })
+ return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' })
}
}
})
diff --git a/src/resources/js/routes.js b/src/resources/js/routes.js
index 9809be8d..1880ba2a 100644
--- a/src/resources/js/routes.js
+++ b/src/resources/js/routes.js
@@ -1,115 +1,122 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
import DashboardComponent from '../vue/Dashboard'
import DomainInfoComponent from '../vue/Domain/Info'
import DomainListComponent from '../vue/Domain/List'
import Error404Component from '../vue/404'
import LoginComponent from '../vue/Login'
import LogoutComponent from '../vue/Logout'
import PasswordResetComponent from '../vue/PasswordReset'
import SignupComponent from '../vue/Signup'
import UserInfoComponent from '../vue/User/Info'
import UserListComponent from '../vue/User/List'
import UserProfileComponent from '../vue/User/Profile'
import UserProfileDeleteComponent from '../vue/User/ProfileDelete'
+import WalletComponent from '../vue/Wallet'
import store from './store'
const routes = [
{
path: '/',
redirect: { name: 'dashboard' }
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainInfoComponent,
meta: { requiresAuth: true }
},
{
path: '/domains',
name: 'domains',
component: DomainListComponent,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
{
path: '/password-reset/:code?',
name: 'password-reset',
component: PasswordResetComponent
},
{
path: '/profile',
name: 'profile',
component: UserProfileComponent,
meta: { requiresAuth: true }
},
{
path: '/profile/delete',
name: 'profile-delete',
component: UserProfileDeleteComponent,
meta: { requiresAuth: true }
},
{
path: '/signup/:param?',
name: 'signup',
component: SignupComponent
},
{
path: '/user/:user',
name: 'user',
component: UserInfoComponent,
meta: { requiresAuth: true }
},
{
path: '/users',
name: 'users',
component: UserListComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/wallet',
+ name: 'wallet',
+ component: WalletComponent,
+ meta: { requiresAuth: true }
+ },
{
name: '404',
path: '*',
component: Error404Component
}
]
const router = new VueRouter({
mode: 'history',
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()
})
export default router
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
index 9b3d3b21..ea39e77f 100644
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -1,166 +1,172 @@
// 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';
nav + .container {
margin-top: 120px;
}
#app {
margin-bottom: 2rem;
}
#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;
}
.listinput {
display: none;
}
.listinput-widget {
& > 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;
}
}
}
}
.range-input {
display: flex;
label {
margin-right: 0.5em;
}
}
table.form-list {
td {
border: 0;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
table {
td.buttons,
td.price,
td.selection {
width: 1%;
}
td.price {
text-align: right;
}
}
#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: .5rem;
+ right: .5rem;
+ }
}
svg {
width: 6rem;
height: 6rem;
}
}
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
index b91f188e..7987c387 100644
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -1,88 +1,99 @@
Status
-
✓○
{{ item.title }}
{{ item.title }}
Your profile
Domains
User accounts
-
+
Wallet
+ {{ $root.price(balance) }}
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
new file mode 100644
index 00000000..8f5aa466
--- /dev/null
+++ b/src/resources/vue/Wallet.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
Account balance
+
+
Current account balance is
+ {{ $root.price(balance) }}
+
+
+
+
+
+
+
+
diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php
new file mode 100644
index 00000000..d1ccd27a
--- /dev/null
+++ b/src/tests/Browser/Pages/Wallet.php
@@ -0,0 +1,44 @@
+assertPathIs($this->url())
+ ->waitUntilMissing('@app .app-loader')
+ ->assertSeeIn('#wallet .card-title', 'Account balance');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ ];
+ }
+}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index 2408fbc6..edde49c6 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,498 +1,498 @@
'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();
Sku::where('title', 'test')->delete();
$storage = Sku::where('title', 'storage')->first();
Entitlement::where([
['sku_id', $storage->id],
['entitleable_id', $john->id],
['cost', 25]
])->delete();
}
/**
* {@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();
Sku::where('title', 'test')->delete();
$storage = Sku::where('title', 'storage')->first();
Entitlement::where([
['sku_id', $storage->id],
['entitleable_id', $john->id],
['cost', 25]
])->delete();
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', 3)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) 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');
});
});
}
/**
* Test user account editing page (not profile page)
*
* @depends testList
*/
public function testInfo(): void
{
Sku::create([
'title' => 'test',
'name' => 'Test SKU',
'description' => 'The SKU for testing',
'cost' => 666,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Groupware',
'active' => true,
]);
$this->browse(function (Browser $browser) {
$browser->on(new UserList())
->click('@table tr:nth-child(2) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', '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]', $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', 'Email')
->assertValue('div.row:nth-child(3) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(3) input[type=text]')
->assertSeeIn('div.row:nth-child(4) label', 'Email aliases')
->assertVisible('div.row:nth-child(4) .listinput-widget')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->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('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();
});
// 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();
});
// 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();
})
->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();
});
$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(7) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(7)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 4)
// groupware SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'Groupware Features')
- ->assertSeeIn('tbody tr:nth-child(1) td.price', 'CHF 5.55/month')
+ ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,55 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertEnabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'User Mailbox')
- ->assertSeeIn('tbody tr:nth-child(2) td.price', 'CHF 4.44/month')
+ ->assertSeeIn('tbody tr:nth-child(2) td.price', '4,44 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',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Storage Quota')
- ->assertSeeIn('tr:nth-child(3) td.price', 'CHF 0.00/month')
+ ->assertSeeIn('tr:nth-child(3) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertDisabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(3) .range-input'), function ($browser) {
$browser->assertQuotaValue(2)->setQuotaValue(3);
})
- ->assertSeeIn('tr:nth-child(3) td.price', 'CHF 0.25/month')
+ ->assertSeeIn('tr:nth-child(3) td.price', '0,25 CHF/month')
// Test SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Test SKU')
- ->assertSeeIn('tbody tr:nth-child(4) td.price', 'CHF 6.66/month')
+ ->assertSeeIn('tbody tr:nth-child(4) td.price', '6,66 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',
'The SKU for testing'
)
->click('tbody tr:nth-child(4) td.selection input');
})
->click('button[type=submit]');
})
->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
$browser->assertToastTitle('')
->assertToastMessage('User data updated successfully')
->closeToast();
});
$this->assertUserEntitlements($john, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'test']);
});
}
/**
* 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) .listinput-widget')
->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')
->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');
})
->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.')
->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);
});
});
// 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();
})
// check redirection to users list
->waitForLocation('/users')
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(3) 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', 4)
->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org')
->click('tbody tr:nth-child(3) 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(3) 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();
})
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 3)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) 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(2) 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', 3)
->assertElementsCount('tbody button.button-delete', 3);
});
// 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
}
}
diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php
new file mode 100644
index 00000000..5ede6560
--- /dev/null
+++ b/src/tests/Browser/WalletTest.php
@@ -0,0 +1,76 @@
+getTestUser('john@kolab.org');
+ Wallet::where('user_id', $john->id)->update(['balance' => -1234]);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ Wallet::where('user_id', $john->id)->update(['balance' => 0]);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test wallet page (unauthenticated)
+ */
+ public function testWalletUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/wallet')->on(new Home());
+ });
+ }
+
+ /**
+ * Test wallet "box" on Dashboard
+ */
+ public function testDashboard(): 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-wallet .name', 'Wallet')
+ ->assertSeeIn('@links .link-wallet .badge', '-12,34 CHF');
+ });
+ }
+
+ /**
+ * Test wallet page
+ *
+ * @depends testDashboard
+ */
+ public function testWallet(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('#wallet .card-title', 'Account balance')
+ ->assertSeeIn('#wallet .card-text', 'Current account balance is -12,34 CHF');
+ });
+ }
+}