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);
}
}