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($('
Loading
')) }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (error.response.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 @@ 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 @@ + + + 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'); + }); + } +}