diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index 7c6917b6..d58f2833 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,172 +1,179 @@ errorResponse(404); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Display the specified resource. * * @param string $id * * @return \Illuminate\Http\JsonResponse */ public function show($id) { return $this->errorResponse(404); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Fetch wallet transactions. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; + $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } - $result = $result->map(function ($item) { + $result = $result->map(function ($item) use ($isAdmin) { $amount = $item->amount; if (in_array($item->type, [Transaction::WALLET_PENALTY, Transaction::WALLET_DEBIT])) { $amount *= -1; } - return [ + $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $amount, 'hasDetails' => !empty($item->cnt), ]; + + if ($isAdmin && $item->user_email) { + $entry['user'] = $item->user_email; + } + + return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } } diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss index 57425037..e79f0fa6 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,271 +1,272 @@ // Fonts // Variables @import 'variables'; // Bootstrap @import '~bootstrap/scss/bootstrap'; @import 'menu'; @import 'toast'; @import 'forms'; html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; & > nav { flex-shrink: 0; z-index: 12; } & > 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; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; } tbody:not(:empty) + & { display: none; } } table { td.buttons, + td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } th.price, td.price { width: 1%; text-align: right; white-space: nowrap; } &.form-list { td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } } .list-details { min-height: 1em; ul { margin: 0; padding-left: 1.2em; } } .btn-action { line-height: 1; padding: 0; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } .plan-selector { .plan-ico { font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; margin-bottom: 1rem; border-radius: 50%; } ul { padding-left: 1.2em; &:last-child { margin-bottom: 0; } } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: .75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } // Bootstrap style fix .btn-link { border: 0; } .table thead th { border: 0; } diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 749e9287..1f3edfc7 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,579 +1,597 @@ diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 6ee3c7e0..f5490161 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,68 +1,68 @@ diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index 66f27a75..34f38df2 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,88 +1,88 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index eadd3b4f..c30bf1dd 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,366 +1,366 @@ diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index 2ee3f308..fda396ab 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,373 +1,287 @@ diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue new file mode 100644 index 00000000..323080d0 --- /dev/null +++ b/src/resources/vue/Widgets/TransactionLog.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php index 949e10e0..ae5dfdac 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,110 +1,111 @@ 'api', 'prefix' => 'auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); } ); Route::group( [ 'domain' => \config('app.domain'), ], function () { Route::post('webhooks/payment/{provider}', 'API\V4\PaymentsController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); + Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); } ); diff --git a/src/tests/Browser/Admin/UserFinancesTest.php b/src/tests/Browser/Admin/UserFinancesTest.php new file mode 100644 index 00000000..625488f3 --- /dev/null +++ b/src/tests/Browser/Admin/UserFinancesTest.php @@ -0,0 +1,314 @@ +getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->balance = 0; + $wallet->save(); + } + + /** + * Test Finances tab (and transactions) + */ + public function testFinances(): void + { + // Assert Jack's Finances tab + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $jack->wallets()->first()->transactions()->delete(); + $page = new UserPage($jack->id); + + $browser->visit(new Home()) + ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) + ->on(new Dashboard()) + ->visit($page) + ->on($page) + ->assertSeeIn('@nav #tab-finances', 'Finances') + ->with('@user-finances', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF') + ->with('form', function (Browser $browser) { + $payment_provider = ucfirst(\config('services.payment_provider')); + $browser->assertElementsCount('.row', 2) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', 'none') + ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID') + ->assertVisible('.row:nth-child(2) a'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) { + $browser->assertMissing('tbody') + ->assertSeeIn('tfoot td', "There are no transactions for this account."); + }) + ->assertMissing('table + button'); + }); + }); + + // Assert John's Finances tab (with discount, and debit) + $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->transactions()->delete(); + $wallet->discount()->associate($discount); + $wallet->debit(2010); + $wallet->save(); + + // Create test transactions + $transaction = Transaction::create([ + 'user_email' => 'jeroen@jeroen.jeroen', + 'object_id' => $wallet->id, + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_CREDIT, + 'amount' => 100, + 'description' => 'Payment', + ]); + $transaction->updated_at = Carbon::now()->previous(Carbon::MONDAY); + $transaction->save(); + + // Click the managed-by link on Jack's page + $browser->click('@user-info #manager a') + ->on($page) + ->with('@user-finances', function (Browser $browser) use ($transaction) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-danger', '-20,10 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 2) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) use ($transaction) { + $browser->assertElementsCount('tbody tr', 2) + ->assertMissing('tfoot') + ->assertSeeIn('tbody tr:last-child td.email', 'jeroen@jeroen.jeroen'); + }); + }); + }); + + // 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('@nav #tab-users') + ->click('@user-users tbody tr:nth-child(3) td:first-child a') + ->on($page) + ->with('@user-finances', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 2) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) { + $browser->assertMissing('tbody') + ->assertSeeIn('tfoot td', "There are no transactions for this account."); + }) + ->assertMissing('table + button'); + }); + }); + } + + /** + * Test editing wallet discount + * + * @depends testFinances + */ + public function testWalletDiscount(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->pause(100) + ->waitUntilMissing('@user-finances .app-loader') + ->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.') + ->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.') + ->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'); + }); + }); + } + + /** + * Test awarding/penalizing a wallet + * + * @depends testFinances + */ + public function testBonusPenalty(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->waitFor('@user-finances #button-award') + ->click('@user-finances #button-award') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Add a bonus to the wallet') + ->assertFocused('@body input#oneoff_amount') + ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount') + ->assertvalue('@body input#oneoff_amount', '') + ->assertSeeIn('@body label[for="oneoff_description"]', 'Description') + ->assertvalue('@body input#oneoff_description', '') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#oneoff-dialog'); + + // Test bonus + $browser->click('@user-finances #button-award') + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + // Test input validation for a bonus + $browser->type('@body #oneoff_amount', 'aaa') + ->type('@body #oneoff_description', '') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #oneoff_amount.is-invalid') + ->assertVisible('@body #oneoff_description.is-invalid') + ->assertSeeIn( + '@body #oneoff_amount + span + .invalid-feedback', + 'The amount must be a number.' + ) + ->assertSeeIn( + '@body #oneoff_description + .invalid-feedback', + 'The description field is required.' + ); + + // Test adding a bonus + $browser->type('@body #oneoff_amount', '12.34') + ->type('@body #oneoff_description', 'Test bonus') + ->click('@button-action') + ->assertToast(Toast::TYPE_SUCCESS, 'The bonus has been added to the wallet successfully.'); + }) + ->assertMissing('#oneoff-dialog') + ->assertSeeIn('@user-finances .card-title span.text-success', '12,34 CHF') + ->waitUntilMissing('.app-loader') + ->with('table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 3) + ->assertMissing('tfoot') + ->assertSeeIn('tbody tr:first-child td.description', 'Bonus: Test bonus') + ->assertSeeIn('tbody tr:first-child td.email', 'jeroen@jeroen.jeroen') + ->assertSeeIn('tbody tr:first-child td.price', '12,34 CHF'); + }); + + $this->assertSame(1234, $john->wallets()->first()->balance); + + // Test penalty + $browser->click('@user-finances #button-penalty') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Add a penalty to the wallet') + ->assertFocused('@body input#oneoff_amount') + ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount') + ->assertvalue('@body input#oneoff_amount', '') + ->assertSeeIn('@body label[for="oneoff_description"]', 'Description') + ->assertvalue('@body input#oneoff_description', '') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#oneoff-dialog') + ->click('@user-finances #button-penalty') + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + // Test input validation for a penalty + $browser->type('@body #oneoff_amount', '') + ->type('@body #oneoff_description', '') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #oneoff_amount.is-invalid') + ->assertVisible('@body #oneoff_description.is-invalid') + ->assertSeeIn( + '@body #oneoff_amount + span + .invalid-feedback', + 'The amount field is required.' + ) + ->assertSeeIn( + '@body #oneoff_description + .invalid-feedback', + 'The description field is required.' + ); + + // Test adding a penalty + $browser->type('@body #oneoff_amount', '12.35') + ->type('@body #oneoff_description', 'Test penalty') + ->click('@button-action') + ->assertToast(Toast::TYPE_SUCCESS, 'The penalty has been added to the wallet successfully.'); + }) + ->assertMissing('#oneoff-dialog') + ->assertSeeIn('@user-finances .card-title span.text-danger', '-0,01 CHF'); + + $this->assertSame(-1, $john->wallets()->first()->balance); + }); + } +} diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php index 5e79c686..eef99650 100644 --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -1,597 +1,399 @@ getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => '+48123123123', 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $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, 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $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->waitUntilMissing('.app-loader') - ->assertSeeIn('.card-title', 'Account balance') - ->assertSeeIn('.card-title .text-success', '0,00 CHF') - ->with('form', function (Browser $browser) { - $payment_provider = ucfirst(\config('services.payment_provider')); - $browser->assertElementsCount('.row', 2) - ->assertSeeIn('.row:nth-child(1) label', 'Discount') - ->assertSeeIn('.row:nth-child(1) #discount span', 'none') - ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID') - ->assertVisible('.row:nth-child(2) a'); - }); - }); + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // 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', 9) ->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', 'Organization') ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') ->assertSeeIn('.row:nth-child(6) label', 'Phone') ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(7) label', 'External email') ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(8) label', 'Address') ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(9) label', 'Country') ->assertSeeIn('.row:nth-child(9) #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->waitUntilMissing('.app-loader') - ->assertSeeIn('.card-title', 'Account balance') - ->assertSeeIn('.card-title .text-danger', '-20,10 CHF') - ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 2) - ->assertSeeIn('.row:nth-child(1) label', 'Discount') - ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); - }); - }); + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // 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->waitUntilMissing('.app-loader') - ->assertSeeIn('.card-title', 'Account balance') - ->assertSeeIn('.card-title .text-success', '0,00 CHF') - ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 2) - ->assertSeeIn('.row:nth-child(1) label', 'Discount') - ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); - }); - }); + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // 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 an external email * * @depends testUserInfo2 */ public function testExternalEmail(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->waitFor('@user-info #external_email button') ->click('@user-info #external_email button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'External email') ->assertFocused('@body input') ->assertValue('@body input', 'john.doe.external@gmail.com') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') ->click('@button-cancel'); }) ->assertMissing('#email-dialog') ->click('@user-info #external_email button') // Test email validation error handling, and email update ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->type('@body input', 'test') ->click('@button-action') ->waitFor('@body input.is-invalid') ->assertSeeIn( '@body input + .invalid-feedback', 'The external email must be a valid email address.' ) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@body input', 'test@test.com') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->assertSeeIn('@user-info #external_email a', 'test@test.com') ->click('@user-info #external_email button') ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertValue('@body input', 'test@test.com') ->assertMissing('@body input.is-invalid') ->assertMissing('@body input + .invalid-feedback') ->click('@button-cancel'); }) ->assertSeeIn('@user-info #external_email a', 'test@test.com'); // $john->getSetting() may not work here as it uses internal cache // read the value form database $current_ext_email = $john->settings()->where('key', 'external_email')->first()->value; $this->assertSame('test@test.com', $current_ext_email); }); } /** * Test suspending/unsuspending the user */ public function testSuspendAndUnsuspend(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend') ->click('@user-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') ->assertMissing('@user-info #button-suspend') ->click('@user-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') ->assertSeeIn('@user-info #status span.text-success', 'Active') ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend'); }); } - - /** - * 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) - ->waitUntilMissing('@user-finances .app-loader') - ->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.') - ->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.') - ->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'); - }); - }); - } - - /** - * Test awarding/penalizing a wallet - */ - public function testBonusPenalty(): void - { - $this->browse(function (Browser $browser) { - $john = $this->getTestUser('john@kolab.org'); - - $browser->visit(new UserPage($john->id)) - ->waitFor('@user-finances #button-award') - ->click('@user-finances #button-award') - // Test dialog content, and closing it with Cancel button - ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { - $browser->assertSeeIn('@title', 'Add a bonus to the wallet') - ->assertFocused('@body input#oneoff_amount') - ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount') - ->assertvalue('@body input#oneoff_amount', '') - ->assertSeeIn('@body label[for="oneoff_description"]', 'Description') - ->assertvalue('@body input#oneoff_description', '') - ->assertSeeIn('@button-cancel', 'Cancel') - ->assertSeeIn('@button-action', 'Submit') - ->click('@button-cancel'); - }) - ->assertMissing('#oneoff-dialog'); - - // Test bonus - $browser->click('@user-finances #button-award') - ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { - // Test input validation for a bonus - $browser->type('@body #oneoff_amount', 'aaa') - ->type('@body #oneoff_description', '') - ->click('@button-action') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error') - ->assertVisible('@body #oneoff_amount.is-invalid') - ->assertVisible('@body #oneoff_description.is-invalid') - ->assertSeeIn( - '@body #oneoff_amount + span + .invalid-feedback', - 'The amount must be a number.' - ) - ->assertSeeIn( - '@body #oneoff_description + .invalid-feedback', - 'The description field is required.' - ); - - // Test adding a bonus - $browser->type('@body #oneoff_amount', '12.34') - ->type('@body #oneoff_description', 'Test bonus') - ->click('@button-action') - ->assertToast(Toast::TYPE_SUCCESS, 'The bonus has been added to the wallet successfully.'); - }) - ->assertMissing('#oneoff-dialog') - ->assertSeeIn('@user-finances .card-title span.text-success', '12,34 CHF'); - - $this->assertSame(1234, $john->wallets()->first()->balance); - - // Test penalty - $browser->click('@user-finances #button-penalty') - // Test dialog content, and closing it with Cancel button - ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { - $browser->assertSeeIn('@title', 'Add a penalty to the wallet') - ->assertFocused('@body input#oneoff_amount') - ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount') - ->assertvalue('@body input#oneoff_amount', '') - ->assertSeeIn('@body label[for="oneoff_description"]', 'Description') - ->assertvalue('@body input#oneoff_description', '') - ->assertSeeIn('@button-cancel', 'Cancel') - ->assertSeeIn('@button-action', 'Submit') - ->click('@button-cancel'); - }) - ->assertMissing('#oneoff-dialog') - ->click('@user-finances #button-penalty') - ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { - // Test input validation for a penalty - $browser->type('@body #oneoff_amount', '') - ->type('@body #oneoff_description', '') - ->click('@button-action') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error') - ->assertVisible('@body #oneoff_amount.is-invalid') - ->assertVisible('@body #oneoff_description.is-invalid') - ->assertSeeIn( - '@body #oneoff_amount + span + .invalid-feedback', - 'The amount field is required.' - ) - ->assertSeeIn( - '@body #oneoff_description + .invalid-feedback', - 'The description field is required.' - ); - - // Test adding a penalty - $browser->type('@body #oneoff_amount', '12.35') - ->type('@body #oneoff_description', 'Test penalty') - ->click('@button-action') - ->assertToast(Toast::TYPE_SUCCESS, 'The penalty has been added to the wallet successfully.'); - }) - ->assertMissing('#oneoff-dialog') - ->assertSeeIn('@user-finances .card-title span.text-danger', '-0,01 CHF'); - - $this->assertSame(-1, $john->wallets()->first()->balance); - }); - } } diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php index 5584198b..b271210d 100644 --- a/src/tests/Browser/WalletTest.php +++ b/src/tests/Browser/WalletTest.php @@ -1,152 +1,153 @@ deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => -1234]); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); $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'); }); } /** * Test History tab */ public function testHistory(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); // Log out John and log in the test user $this->browse(function (Browser $browser) { $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()) ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); }); $package_kolab = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package_kolab); $wallet = $user->wallets()->first(); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); $this->browse(function (Browser $browser) use ($pages, $wallet) { $browser->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('@nav #tab-history', 'History') ->with('@history-tab', function (Browser $browser) use ($pages, $wallet) { $browser->assertElementsCount('table tbody tr', 10) + ->assertMissing('table td.email') ->assertSeeIn('#transactions-loader button', 'Load more'); foreach ($pages[0] as $idx => $transaction) { $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')'; $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger'; $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()) ->assertMissing("$selector td.selection button") ->assertVisible("$selector td.price.{$priceStyle}"); // TODO: Test more transaction details } // Load the next page $browser->click('#transactions-loader button') ->waitUntilMissing('.app-loader') ->assertElementsCount('table tbody tr', 12) ->assertMissing('#transactions-loader button'); $debitEntry = null; foreach ($pages[1] as $idx => $transaction) { $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')'; $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger'; $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()); if ($transaction->type == Transaction::WALLET_DEBIT) { $debitEntry = $selector; } else { $browser->assertMissing("$selector td.selection button"); } } // Load sub-transactions $browser->click("$debitEntry td.selection button") ->waitUntilMissing('.app-loader') ->assertElementsCount("$debitEntry td.description ul li", 2) ->assertMissing("$debitEntry td.selection button"); }); }); } } diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php index 6ca6ac44..68d0782c 100644 --- a/src/tests/Feature/Controller/Admin/WalletsTest.php +++ b/src/tests/Feature/Controller/Admin/WalletsTest.php @@ -1,181 +1,226 @@ 'stripe']); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); // Make sure there's no stripe/mollie identifiers $wallet->setSetting('stripe_id', null); $wallet->setSetting('stripe_mandate_id', null); $wallet->setSetting('mollie_id', null); $wallet->setSetting('mollie_mandate_id', null); // Non-admin user $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($wallet->id, $json['id']); $this->assertSame('CHF', $json['currency']); $this->assertSame($wallet->balance, $json['balance']); $this->assertSame(0, $json['discount']); $this->assertTrue(empty($json['description'])); $this->assertTrue(empty($json['discount_description'])); $this->assertTrue(!empty($json['provider'])); $this->assertTrue(!empty($json['providerLink'])); $this->assertTrue(!empty($json['mandate'])); } /** * Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off) */ public function testOneOff(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $balance = $wallet->balance; Transaction::where('object_id', $wallet->id) ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY]) ->delete(); // Non-admin user $response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); // Admin user - invalid input $post = ['amount' => 'aaaa']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('The amount must be a number.', $json['errors']['amount'][0]); $this->assertSame('The description field is required.', $json['errors']['description'][0]); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); // Admin user - a valid bonus $post = ['amount' => '50', 'description' => 'A bonus']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']); $this->assertSame($balance += 5000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_AWARD)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(5000, $transaction->amount); $this->assertSame($admin->email, $transaction->user_email); // Admin user - a valid penalty $post = ['amount' => '-40', 'description' => 'A penalty']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']); $this->assertSame($balance -= 4000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_PENALTY)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(4000, $transaction->amount); $this->assertSame($admin->email, $transaction->user_email); } + /** + * Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions) + */ + public function testTransactions(): void + { + // Note: Here we're testing only that the end-point works, + // and admin can get the transaction log, response details + // are tested in Feature/Controller/WalletsTest.php + $this->deleteTestUser('wallets-controller@kolabnow.com'); + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Non-admin + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); + $response->assertStatus(403); + + // Create some sample transactions + $transactions = $this->createTestTransactions($wallet); + $transactions = array_reverse($transactions); + $pages = array_chunk($transactions, 10 /* page size*/); + + // Get the 2nd page + $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(5, $json); + $this->assertSame('success', $json['status']); + $this->assertSame(2, $json['page']); + $this->assertSame(2, $json['count']); + $this->assertSame(false, $json['hasMore']); + $this->assertCount(2, $json['list']); + foreach ($pages[1] as $idx => $transaction) { + $this->assertSame($transaction->id, $json['list'][$idx]['id']); + $this->assertSame($transaction->type, $json['list'][$idx]['type']); + $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); + $this->assertFalse($json['list'][$idx]['hasDetails']); + } + + // The 'user' key is set only on the admin end-point + $this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']); + } + /** * Test updating a wallet (PUT /api/v4/wallets/:id) */ public function testUpdate(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $discount = Discount::where('code', 'TEST')->first(); // Non-admin user $response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Admin user - setting a discount $post = ['discount' => $discount->id]; $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame($discount->discount, $json['discount']); $this->assertSame($discount->id, $json['discount_id']); $this->assertSame($discount->description, $json['discount_description']); $this->assertSame($discount->id, $wallet->fresh()->discount->id); // Admin user - removing a discount $post = ['discount' => null]; $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame(null, $json['discount_id']); $this->assertTrue(empty($json['discount_description'])); $this->assertSame(null, $wallet->fresh()->discount); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index 6e718027..332c3d6d 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,150 +1,152 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@klab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); + $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); + $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index c8a6bc31..2a45543f 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,203 +1,203 @@ entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create([ - 'user_email' => null, + 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit, 'description' => 'Payment', ]); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create([ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $types[count($result) % count($types)], 'amount' => 11 * (count($result) + 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\DomainDelete($domain->id); $job->handle(); $domain->forceDelete(); } protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\UserDelete($user->id); $job->handle(); $user->forceDelete(); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return User::firstOrCreate(['email' => $email], $attrib); } if ($user->deleted_at) { $user->restore(); } return $user; } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } }