diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php index c7655f00..bf5dccde 100644 --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -1,316 +1,369 @@ errorResponse(404); } $method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart))); - if (!method_exists($this, $method)) { + if (!in_array($chart, $this->charts) || !method_exists($this, $method)) { return $this->errorResponse(404); } $result = $this->{$method}(); return response()->json($result); } /** * Get discounts chart */ protected function chartDiscounts(): array { $discounts = DB::table('wallets') ->selectRaw("discount, count(discount_id) as cnt") ->join('discounts', 'discounts.id', '=', 'wallets.discount_id') ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNull('users.deleted_at') - ->groupBy('discounts.discount') - ->pluck('cnt', 'discount') - ->all(); + ->groupBy('discounts.discount'); + + $addTenantScope = function ($builder, $tenantId) { + return $builder->where('users.tenant_id', $tenantId); + }; + + $discounts = $this->applyTenantScope($discounts, $addTenantScope) + ->pluck('cnt', 'discount')->all(); $labels = array_keys($discounts); $discounts = array_values($discounts); // $labels = [10, 25, 30, 100]; // $discounts = [100, 120, 30, 50]; $labels = array_map(function ($item) { return $item . '%'; }, $labels); // See https://frappe.io/charts/docs for format/options description return [ 'title' => 'Discounts', 'type' => 'donut', 'colors' => [ self::COLOR_BLUE, self::COLOR_BLUE_DARK, self::COLOR_GREEN, self::COLOR_GREEN_DARK, self::COLOR_ORANGE, self::COLOR_RED, self::COLOR_RED_DARK ], 'maxSlices' => 8, 'tooltipOptions' => [], // does not work without it (https://github.com/frappe/charts/issues/314) 'data' => [ 'labels' => $labels, 'datasets' => [ [ 'values' => $discounts ] ] ] ]; } /** * Get income chart */ protected function chartIncome(): array { $weeks = 8; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $payments = DB::table('payments') ->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(amount) as amount") ->where('updated_at', '>=', $start->toDateString()) ->where('status', PaymentProvider::STATUS_PAID) ->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING]) - ->groupByRaw('1') + ->groupByRaw('1'); + + $addTenantScope = function ($builder, $tenantId) { + $where = '`wallet_id` IN (' + . 'select `id` from `wallets` ' + . 'join `users` on (`wallets`.`user_id` = `users`.`id`) ' + . 'where `payments`.`wallet_id` = `wallets`.`id` ' + . 'and `users`.`tenant_id` = ' . intval($tenantId) + . ')'; + + return $builder->whereRaw($where); + }; + + $payments = $this->applyTenantScope($payments, $addTenantScope) ->pluck('amount', 'period') ->map(function ($amount) { return $amount / 100; }); // TODO: exclude refunds/chargebacks $empty = array_fill_keys($labels, 0); $payments = array_values(array_merge($empty, $payments->all())); // $payments = [1000, 1200.25, 3000, 1897.50, 2000, 1900, 2134, 3330]; $avg = collect($payments)->slice(0, count($labels) - 1)->avg(); // See https://frappe.io/charts/docs for format/options description return [ 'title' => 'Income in CHF - last 8 weeks', 'type' => 'bar', 'colors' => [self::COLOR_BLUE], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Payments', 'values' => $payments ] ], 'yMarkers' => [ [ 'label' => sprintf('average = %.2f', $avg), 'value' => $avg, 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * Get created/deleted users chart */ protected function chartUsers(): array { $weeks = 8; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); + + $created = $this->applyTenantScope($created)->get(); + $deleted = $this->applyTenantScope($deleted)->get(); $empty = array_fill_keys($labels, 0); $created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all())); $deleted = array_values(array_merge($empty, $deleted->pluck('cnt', 'period')->all())); // $created = [5, 2, 4, 2, 0, 5, 2, 4]; // $deleted = [1, 2, 3, 1, 2, 1, 2, 3]; $avg = collect($created)->slice(0, count($labels) - 1)->avg(); // See https://frappe.io/charts/docs for format/options description return [ 'title' => 'Users - last 8 weeks', 'type' => 'bar', // Required to fix https://github.com/frappe/charts/issues/294 'colors' => [self::COLOR_GREEN, self::COLOR_RED], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ 'name' => 'Created', 'chartType' => 'bar', 'values' => $created ], [ 'name' => 'Deleted', 'chartType' => 'line', 'values' => $deleted ] ], 'yMarkers' => [ [ 'label' => sprintf('average = %.1f', $avg), 'value' => collect($created)->avg(), 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * Get all users chart */ protected function chartUsersAll(): array { $weeks = 54; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); - $count = DB::table('users')->whereNull('deleted_at')->count(); + $created = $this->applyTenantScope($created)->get(); + $deleted = $this->applyTenantScope($deleted)->get(); + $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count(); $empty = array_fill_keys($labels, 0); $created = array_merge($empty, $created->pluck('cnt', 'period')->all()); $deleted = array_merge($empty, $deleted->pluck('cnt', 'period')->all()); $all = []; foreach (array_reverse($labels) as $label) { $all[] = $count; $count -= $created[$label] - $deleted[$label]; } $all = array_reverse($all); // $start = 3000; // for ($i = 0; $i < count($labels); $i++) { // $all[$i] = $start + $i * 15; // } // See https://frappe.io/charts/docs for format/options description return [ 'title' => 'All Users - last year', 'type' => 'line', 'colors' => [self::COLOR_GREEN], 'axisOptions' => [ 'xIsSeries' => true, 'xAxisMode' => 'tick', ], 'lineOptions' => [ 'hideDots' => true, 'regionFill' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Existing', 'values' => $all ] ] ] ]; } + + /** + * Add tenant scope to the queries when needed + * + * @param \Illuminate\Database\Query\Builder $query The query + * @param callable $addQuery Additional tenant-scope query-modifier + * + * @return \Illuminate\Database\Query\Builder + */ + protected function applyTenantScope($query, $addQuery = null) + { + $user = Auth::guard()->user(); + + if ($user->role == 'reseller') { + if ($addQuery) { + $query = $addQuery($query, \config('app.tenant_id')); + } else { + $query = $query->withEnvTenant(); + } + } + + // TODO: Tenant selector for admins + + return $query; + } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/StatsController.php b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php new file mode 100644 index 00000000..8491caa8 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php @@ -0,0 +1,14 @@ +
diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue index 7cbe8fba..0b1d4484 100644 --- a/src/resources/vue/Reseller/Dashboard.vue +++ b/src/resources/vue/Reseller/Dashboard.vue @@ -1,24 +1,27 @@ diff --git a/src/resources/vue/Reseller/Stats.vue b/src/resources/vue/Reseller/Stats.vue new file mode 100644 index 00000000..1f20ad3d --- /dev/null +++ b/src/resources/vue/Reseller/Stats.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php index c6dd6bdd..01fd1dfd 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,186 +1,188 @@ 'api', 'prefix' => $prefix . 'api/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' => $prefix . 'api/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::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/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('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); 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::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); 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::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); 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}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); 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); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); Route::group( [ 'domain' => 'reseller.' . \config('app.domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::apiResource('packages', API\V4\Reseller\PackagesController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); + + Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); diff --git a/src/tests/Browser/Reseller/StatsTest.php b/src/tests/Browser/Reseller/StatsTest.php new file mode 100644 index 00000000..8a4830f8 --- /dev/null +++ b/src/tests/Browser/Reseller/StatsTest.php @@ -0,0 +1,54 @@ +browse(function (Browser $browser) { + $browser->visit('/stats')->on(new Home()); + }); + } + + /** + * Test Stats page + */ + public function testStats(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-stats', 'Stats') + ->click('@links .link-stats') + ->on(new Stats()) + ->assertElementsCount('@container > div', 3) + ->waitFor('@container #chart-users svg') + ->assertSeeIn('@container #chart-users svg .title', 'Users - last 8 weeks') + ->waitFor('@container #chart-users-all svg') + ->assertSeeIn('@container #chart-users-all svg .title', 'All Users - last year') + ->waitFor('@container #chart-discounts svg') + ->assertSeeIn('@container #chart-discounts svg .title', 'Discounts'); + }); + } +} diff --git a/src/tests/Feature/Controller/Reseller/DomainsTest.php b/src/tests/Feature/Controller/Reseller/DomainsTest.php index 846dcbe3..b5972346 100644 --- a/src/tests/Feature/Controller/Reseller/DomainsTest.php +++ b/src/tests/Feature/Controller/Reseller/DomainsTest.php @@ -1,308 +1,310 @@ 1]); $this->deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + \config(['app.tenant_id' => 1]); $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domain confirm request */ public function testConfirm(): void { $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // THe end-point exists on the users controller, but not reseller's $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(404); } /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(403); - // Search with no search criteria + // Admin user $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(403); - // Search with no search criteria + // Reseller from a different tenant $response = $this->actingAs($reseller2)->get("api/v4/domains"); $response->assertStatus(403); // Search with no matches expected $response = $this->actingAs($reseller1)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($reseller1)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); // Test unauth access to other tenant's domains \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); $response = $this->actingAs($reseller2)->get("api/v4/domains?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::where('title', 'domain-hosting')->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('test1@domainscontroller.com'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); // Unauthorized access (user) $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Unauthorized access (admin) $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Unauthorized access (tenant != env-tenant) $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); // Note: Other properties are being tested in the user controller tests // Unauthorized access (other domain's tenant) \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(404); } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for resellers $response = $this->actingAs($reseller1)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(404); } /** * Test domain suspending (POST /api/v4/domains//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); \config(['app.tenant_id' => 2]); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); // Test unauthorized access to the reseller API (user) $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to the reseller API (admin) $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to the reseller API (reseller in another tenant) $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test suspending the domain $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($domain->fresh()->isSuspended()); // Test authenticated reseller, but domain belongs to another tenant \config(['app.tenant_id' => 1]); $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(404); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); \config(['app.tenant_id' => 2]); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); // Test unauthorized access to reseller API (user) $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (admin) $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (another tenant) $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (another tenant) \config(['app.tenant_id' => 1]); $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(404); } } diff --git a/src/tests/Feature/Controller/Reseller/InvitationsTest.php b/src/tests/Feature/Controller/Reseller/InvitationsTest.php index bfc383ab..f3e88290 100644 --- a/src/tests/Feature/Controller/Reseller/InvitationsTest.php +++ b/src/tests/Feature/Controller/Reseller/InvitationsTest.php @@ -1,348 +1,350 @@ 1]); + self::useResellerUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { SignupInvitation::truncate(); \config(['app.tenant_id' => 1]); parent::tearDown(); } /** * Test deleting invitations (DELETE /api/v4/invitations/) */ public function testDestroy(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@reseller.com'); $reseller2 = $this->getTestUser('reseller@kolabnow.com'); $tenant = Tenant::where('title', 'Sample Tenant')->first(); \config(['app.tenant_id' => $tenant->id]); $inv = SignupInvitation::create(['email' => 'email1@ext.com']); // Non-admin user $response = $this->actingAs($user)->delete("api/v4/invitations/{$inv->id}"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->delete("api/v4/invitations/{$inv->id}"); $response->assertStatus(403); // Reseller user, but different tenant $response = $this->actingAs($reseller2)->delete("api/v4/invitations/{$inv->id}"); $response->assertStatus(403); // Reseller - non-existing invitation identifier $response = $this->actingAs($reseller)->delete("api/v4/invitations/abd"); $response->assertStatus(404); // Reseller - existing invitation $response = $this->actingAs($reseller)->delete("api/v4/invitations/{$inv->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Invitation deleted successfully.", $json['message']); $this->assertSame(null, SignupInvitation::find($inv->id)); } /** * Test listing invitations (GET /api/v4/invitations) */ public function testIndex(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@reseller.com'); $reseller2 = $this->getTestUser('reseller@kolabnow.com'); $tenant = Tenant::where('title', 'Sample Tenant')->first(); \config(['app.tenant_id' => $tenant->id]); // Non-admin user $response = $this->actingAs($user)->get("api/v4/invitations"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/invitations"); $response->assertStatus(403); // Reseller user, but different tenant $response = $this->actingAs($reseller2)->get("api/v4/invitations"); $response->assertStatus(403); // Reseller (empty list) $response = $this->actingAs($reseller)->get("api/v4/invitations"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertFalse($json['hasMore']); // Add some invitations $i1 = SignupInvitation::create(['email' => 'email1@ext.com']); $i2 = SignupInvitation::create(['email' => 'email2@ext.com']); $i3 = SignupInvitation::create(['email' => 'email3@ext.com']); $i4 = SignupInvitation::create(['email' => 'email4@other.com']); $i5 = SignupInvitation::create(['email' => 'email5@other.com']); $i6 = SignupInvitation::create(['email' => 'email6@other.com']); $i7 = SignupInvitation::create(['email' => 'email7@other.com']); $i8 = SignupInvitation::create(['email' => 'email8@other.com']); $i9 = SignupInvitation::create(['email' => 'email9@other.com']); $i10 = SignupInvitation::create(['email' => 'email10@other.com']); $i11 = SignupInvitation::create(['email' => 'email11@other.com']); $i12 = SignupInvitation::create(['email' => 'email12@test.com']); $i13 = SignupInvitation::create(['email' => 'email13@ext.com']); SignupInvitation::query()->update(['created_at' => now()->subDays('1')]); SignupInvitation::where('id', $i1->id) ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]); SignupInvitation::where('id', $i2->id) ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]); SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]); SignupInvitation::where('id', $i12->id)->update(['tenant_id' => 1]); SignupInvitation::where('id', $i13->id)->update(['tenant_id' => 1]); $response = $this->actingAs($reseller)->get("api/v4/invitations"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(10, $json['count']); $this->assertSame(1, $json['page']); $this->assertTrue($json['hasMore']); $this->assertSame($i1->id, $json['list'][0]['id']); $this->assertSame($i1->email, $json['list'][0]['email']); $this->assertSame(true, $json['list'][0]['isFailed']); $this->assertSame(false, $json['list'][0]['isNew']); $this->assertSame(false, $json['list'][0]['isSent']); $this->assertSame(false, $json['list'][0]['isCompleted']); $this->assertSame($i2->id, $json['list'][1]['id']); $this->assertSame($i2->email, $json['list'][1]['email']); $this->assertFalse(in_array($i12->email, array_column($json['list'], 'email'))); $this->assertFalse(in_array($i13->email, array_column($json['list'], 'email'))); $response = $this->actingAs($reseller)->get("api/v4/invitations?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertSame(2, $json['page']); $this->assertFalse($json['hasMore']); $this->assertSame($i11->id, $json['list'][0]['id']); // Test searching (email address) $response = $this->actingAs($reseller)->get("api/v4/invitations?search=email3@ext.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertSame(1, $json['page']); $this->assertFalse($json['hasMore']); $this->assertSame($i3->id, $json['list'][0]['id']); // Test searching (domain) $response = $this->actingAs($reseller)->get("api/v4/invitations?search=ext.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(3, $json['count']); $this->assertSame(1, $json['page']); $this->assertFalse($json['hasMore']); $this->assertSame($i1->id, $json['list'][0]['id']); } /** * Test resending invitations (POST /api/v4/invitations//resend) */ public function testResend(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@reseller.com'); $reseller2 = $this->getTestUser('reseller@kolabnow.com'); $tenant = Tenant::where('title', 'Sample Tenant')->first(); \config(['app.tenant_id' => $tenant->id]); $inv = SignupInvitation::create(['email' => 'email1@ext.com']); SignupInvitation::where('id', $inv->id)->update(['status' => SignupInvitation::STATUS_FAILED]); // Non-admin user $response = $this->actingAs($user)->post("api/v4/invitations/{$inv->id}/resend"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->post("api/v4/invitations/{$inv->id}/resend"); $response->assertStatus(403); // Reseller user, but different tenant $response = $this->actingAs($reseller2)->post("api/v4/invitations/{$inv->id}/resend"); $response->assertStatus(403); // Reseller - non-existing invitation identifier $response = $this->actingAs($reseller)->post("api/v4/invitations/abd/resend"); $response->assertStatus(404); // Reseller - existing invitation $response = $this->actingAs($reseller)->post("api/v4/invitations/{$inv->id}/resend"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Invitation added to the sending queue successfully.", $json['message']); $this->assertTrue($inv->fresh()->isNew()); } /** * Test creating invitations (POST /api/v4/invitations) */ public function testStore(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@reseller.com'); $reseller2 = $this->getTestUser('reseller@kolabnow.com'); $tenant = Tenant::where('title', 'Sample Tenant')->first(); \config(['app.tenant_id' => $tenant->id]); // Non-admin user $response = $this->actingAs($user)->post("api/v4/invitations", []); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->post("api/v4/invitations", []); $response->assertStatus(403); // Reseller user, but different tenant $response = $this->actingAs($reseller2)->post("api/v4/invitations", []); $response->assertStatus(403); // Reseller (empty post) $response = $this->actingAs($reseller)->post("api/v4/invitations", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The email field is required.", $json['errors']['email'][0]); // Invalid email address $post = ['email' => 'test']; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The email must be a valid email address.", $json['errors']['email'][0]); // Valid email address $post = ['email' => 'test@external.org']; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("The invitation has been created.", $json['message']); $this->assertSame(1, $json['count']); $this->assertSame(1, SignupInvitation::count()); // Test file input (empty file) $tmpfile = tmpfile(); fwrite($tmpfile, ""); $file = new File('test.csv', $tmpfile); $post = ['file' => $file]; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); fclose($tmpfile); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Failed to find any valid email addresses in the uploaded file.", $json['errors']['file']); // Test file input with an invalid email address $tmpfile = tmpfile(); fwrite($tmpfile, "t1@domain.tld\r\nt2@domain"); $file = new File('test.csv', $tmpfile); $post = ['file' => $file]; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); fclose($tmpfile); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Found an invalid email address (t2@domain) on line 2.", $json['errors']['file']); // Test file input (two addresses) $tmpfile = tmpfile(); fwrite($tmpfile, "t1@domain.tld\r\nt2@domain.tld"); $file = new File('test.csv', $tmpfile); $post = ['file' => $file]; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); fclose($tmpfile); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, SignupInvitation::where('email', 't1@domain.tld')->count()); $this->assertSame(1, SignupInvitation::where('email', 't2@domain.tld')->count()); $this->assertSame('success', $json['status']); $this->assertSame("2 invitations has been created.", $json['message']); $this->assertSame(2, $json['count']); } } diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php index d948c7c7..a4c8c960 100644 --- a/src/tests/Feature/Controller/Reseller/SkusTest.php +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -1,118 +1,122 @@ 1]); + $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { + \config(['app.tenant_id' => 1]); + $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test fetching SKUs list */ public function testIndex(): void { $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed on admin API $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($reseller1)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(8, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); // TODO: Test limiting SKUs to the tenant's SKUs } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Reseller from another tenant not allowed $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Reseller access $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(8, $json); // Note: Details are tested where we test API\V4\SkusController // Reseller from another tenant not allowed \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/Controller/Reseller/StatsTest.php b/src/tests/Feature/Controller/Reseller/StatsTest.php new file mode 100644 index 00000000..36039681 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/StatsTest.php @@ -0,0 +1,89 @@ +) + */ + public function testChart(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + // Unauth access + $response = $this->get("api/v4/stats/chart/discounts"); + $response->assertStatus(401); + + // Normal user + $response = $this->actingAs($user)->get("api/v4/stats/chart/discounts"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/stats/chart/discounts"); + $response->assertStatus(403); + + // Unknown chart name + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/unknown"); + $response->assertStatus(404); + + // 'income' chart + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/income"); + $response->assertStatus(404); + + // 'discounts' chart + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/discounts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Discounts', $json['title']); + $this->assertSame('donut', $json['type']); + $this->assertSame([], $json['data']['labels']); + $this->assertSame([['values' => []]], $json['data']['datasets']); + + // 'users' chart + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Users - last 8 weeks', $json['title']); + $this->assertCount(8, $json['data']['labels']); + $this->assertSame(date('Y-W'), $json['data']['labels'][7]); + $this->assertCount(2, $json['data']['datasets']); + $this->assertSame('Created', $json['data']['datasets'][0]['name']); + $this->assertSame('Deleted', $json['data']['datasets'][1]['name']); + + // 'users-all' chart + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users-all"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('All Users - last year', $json['title']); + $this->assertCount(54, $json['data']['labels']); + $this->assertCount(1, $json['data']['datasets']); + } +} diff --git a/src/tests/Feature/Controller/Reseller/WalletsTest.php b/src/tests/Feature/Controller/Reseller/WalletsTest.php index 58bdf7e2..2469f5c1 100644 --- a/src/tests/Feature/Controller/Reseller/WalletsTest.php +++ b/src/tests/Feature/Controller/Reseller/WalletsTest.php @@ -1,290 +1,292 @@ 1]); } /** * {@inheritDoc} */ public function tearDown(): void { + \config(['app.tenant_id' => 1]); parent::tearDown(); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) * * @group stripe */ public function testShow(): void { \config(['services.payment_provider' => 'stripe']); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $wallet = $user->wallets()->first(); $wallet->discount_id = null; $wallet->save(); // 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(403); // Reseller from a different tenant $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Reseller $response = $this->actingAs($reseller1)->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'])); // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(404); } /** * 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'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $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 $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); // Reseller from a different tenant $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); // Admin user - invalid input $post = ['amount' => 'aaaa']; $response = $this->actingAs($reseller1)->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($reseller1)->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($reseller1->email, $transaction->user_email); // Admin user - a valid penalty $post = ['amount' => '-40', 'description' => 'A penalty']; $response = $this->actingAs($reseller1)->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($reseller1->email, $transaction->user_email); // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(404); } /** * 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'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); // Non-admin $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Admin $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Reseller from a different tenant $response = $this->actingAs($reseller2)->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($reseller1)->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/reseller end-point // FIXME: Should we hide this for resellers? $this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']); // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); } /** * Test updating a wallet (PUT /api/v4/wallets/:id) */ public function testUpdate(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $user = $this->getTestUser('john@kolab.org'); $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 $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Reseller from another tenant $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Admin user - setting a discount $post = ['discount' => $discount->id]; $response = $this->actingAs($reseller1)->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($reseller1)->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); // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(404); } }