Page MenuHomePhorge

D1012.1775393008.diff
No OneTemporary

Authored By
Unknown
Size
75 KB
Referenced Files
None
Subscribers
None

D1012.1775393008.diff

diff --git a/src/app/Handlers/DomainRelay.php b/src/app/Handlers/DomainRelay.php
new file mode 100644
--- /dev/null
+++ b/src/app/Handlers/DomainRelay.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Handlers;
+
+class DomainRelay extends \App\Handlers\Base
+{
+ public static function entitleableClass()
+ {
+ return \App\Domain::class;
+ }
+
+ public static function preReq($entitlement, $domain)
+ {
+ if (!$entitlement->sku->active) {
+ \Log::error("Sku not active");
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php
--- a/src/app/Handlers/Storage.php
+++ b/src/app/Handlers/Storage.php
@@ -4,6 +4,9 @@
class Storage extends \App\Handlers\Base
{
+ public const MAX_ITEMS = 100;
+ public const ITEM_UNIT = 'GB';
+
public static function entitleableClass()
{
return \App\User::class;
diff --git a/src/app/Http/Controllers/API/EntitlementsController.php b/src/app/Http/Controllers/API/EntitlementsController.php
--- a/src/app/Http/Controllers/API/EntitlementsController.php
+++ b/src/app/Http/Controllers/API/EntitlementsController.php
@@ -8,83 +8,90 @@
class EntitlementsController extends Controller
{
/**
- * Display a listing of the resource.
+ * Show the form for creating a new resource.
*
- * @return \Illuminate\Http\Response
+ * @return \Illuminate\Http\JsonResponse
*/
- public function index()
+ public function create()
{
- //
+ // TODO
+ return $this->errorResponse(404);
}
/**
- * Show the form for creating a new resource.
+ * Remove the specified resource from storage.
*
- * @return \Illuminate\Http\Response
+ * @param int $id
+ *
+ * @return \Illuminate\Http\JsonResponse
*/
- public function create()
+ public function destroy($id)
{
- //
+ // TODO
+ return $this->errorResponse(404);
}
/**
- * Store a newly created resource in storage.
+ * Show the form for editing the specified resource.
*
- * @param \Illuminate\Http\Request $request
+ * @param int $id
*
- * @return \Illuminate\Http\Response
+ * @return \Illuminate\Http\JsonResponse
*/
- public function store(Request $request)
+ public function edit($id)
{
- //
+ // TODO
+ return $this->errorResponse(404);
}
/**
- * Display the specified resource.
- *
- * @param int $id
+ * Display a listing of the resource.
*
- * @return \Illuminate\Http\Response
+ * @return \Illuminate\Http\JsonResponse
*/
- public function show($id)
+ public function index()
{
- //
+ // TODO
+ return $this->errorResponse(404);
}
/**
- * Show the form for editing the specified resource.
+ * Store a newly created resource in storage.
*
- * @param int $id
+ * @param \Illuminate\Http\Request $request
*
- * @return \Illuminate\Http\Response
+ * @return \Illuminate\Http\JsonResponse
*/
- public function edit($id)
+ public function store(Request $request)
{
- //
+ // TODO
+ return $this->errorResponse(404);
}
/**
- * Update the specified resource in storage.
+ * Display the specified resource.
*
- * @param \Illuminate\Http\Request $request
- * @param int $id
+ * @param int $id
*
- * @return \Illuminate\Http\Response
+ * @return \Illuminate\Http\JsonResponse
*/
- public function update(Request $request, $id)
+ public function show($id)
{
- //
+ // TODO
+ return $this->errorResponse(404);
}
/**
- * Remove the specified resource from storage.
+ * Update the specified resource in storage.
*
- * @param int $id
+ * @param \Illuminate\Http\Request $request
+ * @param int $id
*
- * @return \Illuminate\Http\Response
+ * @return \Illuminate\Http\JsonResponse
*/
- public function destroy($id)
+ public function update(Request $request, $id)
{
- //
+ // TODO
+ return $this->errorResponse(404);
}
}
diff --git a/src/app/Http/Controllers/API/PackagesController.php b/src/app/Http/Controllers/API/PackagesController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/PackagesController.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace App\Http\Controllers\API;
+
+use App\Package;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+
+class PackagesController extends Controller
+{
+ /**
+ * Show the form for creating a new package.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Remove the specified package from storage.
+ *
+ * @param int $id Package identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function destroy($id)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Show the form for editing the specified package.
+ *
+ * @param int $id Package identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function edit($id)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Display a listing of packages.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ // TODO: Packages should have an 'active' flag too, I guess
+ $response = [];
+ $packages = Package::select()->orderBy('title')->get();
+
+ foreach ($packages as $package) {
+ $response[] = [
+ 'id' => $package->id,
+ 'title' => $package->title,
+ 'name' => $package->name,
+ 'description' => $package->description,
+ 'cost' => $package->cost(),
+ 'isDomain' => $package->isDomain(),
+ ];
+ }
+
+ return response()->json($response);
+ }
+
+ /**
+ * Store a newly created package in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function store(Request $request)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Display the specified package.
+ *
+ * @param int $id Package identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Update the specified package in storage.
+ *
+ * @param \Illuminate\Http\Request $request Request object
+ * @param int $id Package identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function update(Request $request, $id)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+}
diff --git a/src/app/Http/Controllers/API/SkusController.php b/src/app/Http/Controllers/API/SkusController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/SkusController.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace App\Http\Controllers\API;
+
+use App\Http\Controllers\Controller;
+use App\Sku;
+use Illuminate\Http\Request;
+
+class SkusController extends Controller
+{
+ /**
+ * Show the form for creating a new sku.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Remove the specified sku from storage.
+ *
+ * @param int $id SKU identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function destroy($id)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Show the form for editing the specified sku.
+ *
+ * @param int $id SKU identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function edit($id)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Display a listing of the sku.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $response = [];
+ $skus = Sku::select()->orderBy('title')->get();
+
+ // Note: we do not limit the result to active SKUs only.
+ // It's because we might need users assigned to old SKUs,
+ // we need to display these old SKUs on the entitlements list
+
+ foreach ($skus as $sku) {
+ if ($data = $this->skuElement($sku)) {
+ $response[] = $data;
+ }
+ }
+
+ return response()->json($response);
+ }
+
+ /**
+ * Store a newly created sku in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function store(Request $request)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Display the specified sku.
+ *
+ * @param int $id SKU identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Update the specified sku in storage.
+ *
+ * @param \Illuminate\Http\Request $request Request object
+ * @param int $id SKU identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function update(Request $request, $id)
+ {
+ // TODO
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Convert SKU information to metadata used by UI to
+ * display the form control
+ *
+ * @param \App\Sku $sku SKU object
+ *
+ * @return array|null Metadata
+ */
+ protected function skuElement($sku): ?array
+ {
+ $type = $sku->handler_class::entitleableClass();
+
+ // ignore incomplete handlers
+ if (!$type) {
+ return null;
+ }
+
+ $type = explode('\\', $type);
+ $type = strtolower(end($type));
+
+ $handler = explode('\\', $sku->handler_class);
+ $handler = strtolower(end($handler));
+
+ $data = $sku->toArray();
+
+ $data['type'] = $type;
+ $data['handler'] = $handler;
+ $data['readonly'] = false;
+ $data['enabled'] = false;
+
+ // Use localized value, toArray() does not get them right
+ $data['name'] = $sku->name;
+ $data['description'] = $sku->description;
+
+ unset($data['handler_class']);
+
+ switch ($handler) {
+ case 'storage':
+ // Quota range input
+ $data['readonly'] = true; // only the checkbox will be disabled, not range
+ $data['enabled'] = true;
+ $data['range'] = [
+ 'min' => $data['units_free'],
+ 'max' => $sku->handler_class::MAX_ITEMS,
+ 'unit' => $sku->handler_class::ITEM_UNIT,
+ ];
+ break;
+
+ case 'mailbox':
+ // Mailbox is always enabled and cannot be unset
+ $data['readonly'] = true;
+ $data['enabled'] = true;
+ break;
+ }
+
+ return $data;
+ }
+}
diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php
--- a/src/app/Http/Controllers/API/UsersController.php
+++ b/src/app/Http/Controllers/API/UsersController.php
@@ -6,6 +6,7 @@
use App\Domain;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
+use App\Sku;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -180,7 +181,7 @@
*
* @param int $id The account to show information for.
*
- * @return \Illuminate\Http\JsonResponse|void
+ * @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
@@ -196,6 +197,17 @@
$response = $this->userResponse($user);
+ // Simplified Entitlement/SKU information,
+ // TODO: I agree this format may need to be extended in future
+ $response['skus'] = [];
+ foreach ($user->entitlements as $ent) {
+ $sku = $ent->sku;
+ $response['skus'][$sku->id] = [
+// 'cost' => $ent->cost,
+ 'count' => isset($response['skus'][$sku->id]) ? $response['skus'][$sku->id]['count'] + 1 : 1,
+ ];
+ }
+
return response()->json($response);
}
@@ -268,8 +280,9 @@
public function store(Request $request)
{
$current_user = $this->guard()->user();
+ $owner = $current_user->wallet()->owner;
- if ($current_user->wallet()->owner->id != $current_user->id) {
+ if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
@@ -277,6 +290,16 @@
return $error_response;
}
+ if (empty($request->package) || !($package = \App\Package::find($request->package))) {
+ $errors = ['package' => \trans('validation.packagerequired')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ if ($package->isDomain()) {
+ $errors = ['package' => \trans('validation.packageinvalid')];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
$user_name = !empty($settings['first_name']) ? $settings['first_name'] : '';
if (!empty($settings['last_name'])) {
$user_name .= ' ' . $settings['last_name'];
@@ -291,13 +314,12 @@
'password' => $request->password,
]);
+ $owner->assignPackage($package, $user);
+
if (!empty($settings)) {
$user->setSettings($settings);
}
- // TODO: Assign package
-
- // Add aliases
if (!empty($request->aliases)) {
$user->setAliases($request->aliases);
}
@@ -326,8 +348,10 @@
return $this->errorResponse(404);
}
+ $current_user = $this->guard()->user();
+
// TODO: Decide what attributes a user can change on his own profile
- if (!$this->guard()->user()->canUpdate($user)) {
+ if (!$current_user->canUpdate($user)) {
return $this->errorResponse(403);
}
@@ -335,23 +359,32 @@
return $error_response;
}
+ // Entitlements, only controller can do that
+ if ($request->skus !== null && !$current_user->canDelete($user)) {
+ return $this->errorResponse(422, "You have no permission to change entitlements");
+ }
+
DB::beginTransaction();
+ $this->updateEntitlements($user, $request->skus);
+
if (!empty($settings)) {
$user->setSettings($settings);
}
- // Update user password
if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
- // Update aliases
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
+ // TODO: Make sure that UserUpdate job is created in case of entitlements update
+ // and no password change. So, for example quota change is applied to LDAP
+ // TODO: Review use of $user->save() in the above context
+
DB::commit();
return response()->json([
@@ -371,6 +404,55 @@
}
/**
+ * Update user entitlements.
+ *
+ * @param \App\User $user The user
+ * @param array|null $skus Set of SKUs for the user
+ */
+ protected function updateEntitlements(User $user, $skus)
+ {
+ if (!is_array($skus)) {
+ return;
+ }
+
+ // Existing SKUs
+ // FIXME: Is there really no query builder method to get result indexed
+ // by some column or primary key?
+ $all_skus = Sku::all()->mapWithKeys(function ($sku) {
+ return [$sku->id => $sku];
+ });
+
+ // Existing user entitlements
+ // Note: We sort them by cost, so e.g. for storage we get these free first
+ $entitlements = $user->entitlements()->orderBy('cost')->get();
+
+ // Go through existing entitlements and remove those no longer needed
+ foreach ($entitlements as $ent) {
+ $sku_id = $ent->sku_id;
+
+ if (array_key_exists($sku_id, $skus)) {
+ // An existing entitlement exists on the requested list
+ $skus[$sku_id] -= 1;
+
+ if ($skus[$sku_id] < 0) {
+ $ent->delete();
+ }
+ } elseif ($all_skus[$sku_id]->handler_class != \App\Handlers\Mailbox::class) {
+ // An existing entitlement does not exists on the requested list
+ // Never delete 'mailbox' SKU
+ $ent->delete();
+ }
+ }
+
+ // Add missing entitlements
+ foreach ($skus as $sku_id => $count) {
+ if ($count > 0 && $all_skus->has($sku_id)) {
+ $user->assignSku($all_skus[$sku_id], $count);
+ }
+ }
+ }
+
+ /**
* Create a response data array for specified user.
*
* @param \App\User $user User object
diff --git a/src/app/Package.php b/src/app/Package.php
--- a/src/app/Package.php
+++ b/src/app/Package.php
@@ -3,6 +3,7 @@
namespace App;
use Illuminate\Database\Eloquent\Model;
+use Spatie\Translatable\HasTranslations;
/**
* The eloquent definition of a Package.
@@ -23,15 +24,24 @@
*/
class Package extends Model
{
+ use HasTranslations;
+
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $fillable = [
+ 'description',
+ 'discount_rate',
+ 'name',
'title',
+ ];
+
+ /** @var array Translatable properties */
+ public $translatable = [
+ 'name',
'description',
- 'discount_rate'
];
/**
diff --git a/src/app/Sku.php b/src/app/Sku.php
--- a/src/app/Sku.php
+++ b/src/app/Sku.php
@@ -3,12 +3,15 @@
namespace App;
use Illuminate\Database\Eloquent\Model;
+use Spatie\Translatable\HasTranslations;
/**
* The eloquent definition of a Stock Keeping Unit (SKU).
*/
class Sku extends Model
{
+ use HasTranslations;
+
public $incrementing = false;
protected $keyType = 'string';
@@ -17,14 +20,21 @@
];
protected $fillable = [
- 'title',
- 'description',
+ 'active',
'cost',
- 'units_free',
+ 'description',
+ 'handler_class',
+ 'name',
// persist for annual domain registration
'period',
- 'handler_class',
- 'active'
+ 'title',
+ 'units_free',
+ ];
+
+ /** @var array Translatable properties */
+ public $translatable = [
+ 'name',
+ 'description',
];
/**
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -129,7 +129,7 @@
$user = $this;
}
- $wallet_id = $this->wallets()->get()[0]->id;
+ $wallet_id = $this->wallets()->first()->id;
foreach ($package->skus as $sku) {
for ($i = $sku->pivot->qty; $i > 0; $i--) {
@@ -149,7 +149,15 @@
return $user;
}
- public function assignPlan($plan, $domain = null)
+ /**
+ * Assign a package plan to a user.
+ *
+ * @param \App\Plan $plan The plan to assign
+ * @param \App\Domain $domain Optional domain object
+ *
+ * @return \App\User Self
+ */
+ public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
@@ -160,6 +168,47 @@
$this->assignPackage($package);
}
}
+
+ return $this;
+ }
+
+ /**
+ * Assign a Sku to a user.
+ *
+ * @param \App\Sku $sku The sku to assign.
+ * @param int $count Count of entitlements to add
+ *
+ * @return \App\User Self
+ * @throws \Exception
+ */
+ public function assignSku($sku, int $count = 1): User
+ {
+ // TODO: I guess wallet could be parametrized in future
+ $wallet = $this->wallet();
+ $owner = $wallet->owner;
+ $exists = $this->entitlements()->where('sku_id', $sku->id)->count();
+
+ // TODO: Sanity check, this probably should be in preReq() on handlers
+ // or in EntitlementObserver
+ if ($sku->handler_class::entitleableClass() != User::class) {
+ throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user");
+ }
+
+ while ($count > 0) {
+ \App\Entitlement::create([
+ 'owner_id' => $owner->id,
+ 'wallet_id' => $wallet->id,
+ 'sku_id' => $sku->id,
+ 'cost' => $sku->units_free >= $exists ? $sku->cost : 0,
+ 'entitleable_id' => $this->id,
+ 'entitleable_type' => User::class
+ ]);
+
+ $exists++;
+ $count--;
+ }
+
+ return $this;
}
/**
diff --git a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php
--- a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php
+++ b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php
@@ -19,7 +19,8 @@
function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->string('title', 64);
- $table->text('description')->nullable();
+ $table->json('name');
+ $table->json('description');
$table->integer('cost');
$table->smallinteger('units_free')->default('0');
$table->string('period', strlen('monthly'))->default('monthly');
diff --git a/src/database/migrations/2019_12_10_095027_create_packages_table.php b/src/database/migrations/2019_12_10_095027_create_packages_table.php
--- a/src/database/migrations/2019_12_10_095027_create_packages_table.php
+++ b/src/database/migrations/2019_12_10_095027_create_packages_table.php
@@ -18,7 +18,8 @@
function (Blueprint $table) {
$table->string('id', 36);
$table->string('title', 36);
- $table->string('description', 128);
+ $table->json('name');
+ $table->json('description');
$table->integer('discount_rate')->default(0)->nullable();
$table->primary('id');
diff --git a/src/database/seeds/PackageSeeder.php b/src/database/seeds/PackageSeeder.php
--- a/src/database/seeds/PackageSeeder.php
+++ b/src/database/seeds/PackageSeeder.php
@@ -21,6 +21,7 @@
$package = Package::create(
[
'title' => 'kolab',
+ 'name' => 'Groupware Account',
'description' => 'A fully functional groupware account.',
'discount_rate' => 0
]
@@ -45,6 +46,7 @@
$package = Package::create(
[
'title' => 'lite',
+ 'name' => 'Lite Account',
'description' => 'Just mail and no more.',
'discount_rate' => 0
]
@@ -66,6 +68,7 @@
$package = Package::create(
[
'title' => 'domain-hosting',
+ 'name' => 'Domain Hosting',
'description' => 'Use your own, existing domain.',
'discount_rate' => 0
]
diff --git a/src/database/seeds/SkuSeeder.php b/src/database/seeds/SkuSeeder.php
--- a/src/database/seeds/SkuSeeder.php
+++ b/src/database/seeds/SkuSeeder.php
@@ -15,6 +15,7 @@
Sku::create(
[
'title' => 'mailbox',
+ 'name' => 'User Mailbox',
'description' => 'Just a mailbox',
'cost' => 444,
'units_free' => 0,
@@ -27,17 +28,19 @@
Sku::create(
[
'title' => 'domain',
+ 'name' => 'Hosted Domain',
'description' => 'Somewhere to place a mailbox',
'cost' => 100,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Domain',
- 'active' => true,
+ 'active' => false,
]
);
Sku::create(
[
'title' => 'domain-registration',
+ 'name' => 'Domain Registration',
'description' => 'Register a domain with us',
'cost' => 101,
'period' => 'yearly',
@@ -49,6 +52,7 @@
Sku::create(
[
'title' => 'domain-hosting',
+ 'name' => 'External Domain',
'description' => 'Host a domain that is externally registered',
'cost' => 100,
'units_free' => 1,
@@ -61,6 +65,7 @@
Sku::create(
[
'title' => 'domain-relay',
+ 'name' => 'Domain Relay',
'description' => 'A domain you host at home, for which we relay email',
'cost' => 103,
'period' => 'monthly',
@@ -72,6 +77,7 @@
Sku::create(
[
'title' => 'storage',
+ 'name' => 'Storage Quota',
'description' => 'Some wiggle room',
'cost' => 25,
'units_free' => 2,
@@ -84,7 +90,8 @@
Sku::create(
[
'title' => 'groupware',
- 'description' => 'groupware functions',
+ 'name' => 'Groupware Features',
+ 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.',
'cost' => 555,
'units_free' => 0,
'period' => 'monthly',
@@ -96,6 +103,7 @@
Sku::create(
[
'title' => 'resource',
+ 'name' => 'Resource',
'description' => 'Reservation taker',
'cost' => 101,
'period' => 'monthly',
@@ -107,6 +115,7 @@
Sku::create(
[
'title' => 'shared_folder',
+ 'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
'period' => 'monthly',
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -1,6 +1,8 @@
includes:
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
+ excludes_analyse:
+ - tests/Browser
inferPrivatePropertyTypeFromConstructor: true
ignoreErrors:
- '#Call to an undefined method Illuminate\\Contracts\\Auth\\Guard::factory#'
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -6,8 +6,6 @@
require('./bootstrap')
-window.Vue = require('vue')
-
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Menu'
import router from './routes'
@@ -15,6 +13,8 @@
import FontAwesomeIcon from './fontawesome'
import VueToastr from '@deveodk/vue-toastr'
+window.Vue = require('vue')
+
Vue.component('svg-icon', FontAwesomeIcon)
Vue.use(VueToastr, {
@@ -22,6 +22,30 @@
defaultTimeout: 5000
})
+const vTooltip = (el, binding) => {
+ const t = []
+
+ if (binding.modifiers.focus) t.push('focus')
+ if (binding.modifiers.hover) t.push('hover')
+ if (binding.modifiers.click) t.push('click')
+ if (!t.length) t.push('hover')
+
+ $(el).tooltip({
+ title: binding.value,
+ placement: binding.arg || 'top',
+ trigger: t.join(' '),
+ html: !!binding.modifiers.html,
+ });
+}
+
+Vue.directive('tooltip', {
+ bind: vTooltip,
+ update: vTooltip,
+ unbind (el) {
+ $(el).tooltip('dispose')
+ }
+})
+
// Add a response interceptor for general/validation error handler
// This have to be before Vue and Router setup. Otherwise we would
// not be able to handle axios responses initiated from inside
@@ -194,6 +218,9 @@
} else {
this.errorPage(error.response.status, error.response.statusText)
}
+ },
+ price(price) {
+ return (price/100).toLocaleString('de-CH', { style: 'currency', currency: 'CHF' })
}
}
})
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -6,7 +6,9 @@
import {
faCheck,
faGlobe,
+ faInfoCircle,
faSyncAlt,
+ faTrashAlt,
faUser,
faUserCog,
faUsers,
@@ -17,7 +19,9 @@
library.add(
faCheck,
faGlobe,
+ faInfoCircle,
faSyncAlt,
+ faTrashAlt,
faUser,
faUserCog,
faUsers,
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -123,6 +123,8 @@
'loginexists' => 'The specified login is not available.',
'domainexists' => 'The specified domain is not available.',
'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.',
+ 'packageinvalid' => 'Invalid package selected.',
+ 'packagerequired' => 'Package is required.',
'usernotexists' => 'Unable to find user.',
'noextemail' => 'This user has no external email address.',
'entryinvalid' => 'The specified :attribute is invalid.',
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -105,6 +105,38 @@
}
}
+.range-input {
+ display: flex;
+
+ label {
+ margin-right: 0.5em;
+ }
+}
+
+table.form-list {
+ td {
+ border: 0;
+
+ &:first-child {
+ padding-left: 0;
+ }
+
+ &:last-child {
+ padding-right: 0;
+ }
+ }
+
+ .buttons,
+ .price,
+ .selection {
+ width: 1%;
+ }
+
+ .price {
+ text-align: right;
+ }
+}
+
#dashboard-nav {
display: flex;
flex-wrap: wrap;
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -42,6 +42,83 @@
<input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
</div>
</div>
+ <div v-if="user_id === 'new'" id="user-packages" class="form-group row">
+ <label class="col-sm-4 col-form-label">Package</label>
+ <div class="col-sm-8">
+ <table class="table form-list">
+ <thead class="thead-light sr-only">
+ <tr>
+ <th scope="col"></th>
+ <th scope="col">Package</th>
+ <th scope="col">Price</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
+ <td class="selection">
+ <input type="checkbox" :value="pkg.id" @click="selectPackage" :checked="pkg.id == package_id">
+ </td>
+ <td class="name">
+ {{ pkg.name }}
+ </td>
+ <td class="price text-nowrap">
+ {{ $root.price(pkg.cost) + '/month' }}
+ </td>
+ <td class="buttons">
+ <button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="pkg.description">
+ <svg-icon icon="info-circle"></svg-icon>
+ <span class="sr-only">More information</span>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div v-if="user_id !== 'new'" id="user-skus" class="form-group row">
+ <label class="col-sm-4 col-form-label">Subscriptions</label>
+ <div class="col-sm-8">
+ <table class="table form-list">
+ <thead class="thead-light sr-only">
+ <tr>
+ <th scope="col"></th>
+ <th scope="col">Subscription</th>
+ <th scope="col">Price</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
+ <td class="selection">
+ <input type="checkbox" :value="sku.id" :disabled="sku.readonly" :checked="sku.enabled">
+ </td>
+ <td class="name">
+ <span class="name">{{ sku.name }}</span>
+ <div v-if="sku.range" class="range-input">
+ <label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
+ <input
+ type="range" class="custom-range" @input="rangeUpdate"
+ :value="sku.value || sku.range.min"
+ :min="sku.range.min"
+ :max="sku.range.max"
+ >
+ </div>
+ </td>
+ <td class="price text-nowrap">
+ {{ $root.price(sku.cost) + '/month' }}
+ </td>
+ <td class="buttons">
+ <button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="sku.description">
+ <svg-icon icon="info-circle"></svg-icon>
+ <span class="sr-only">More information</span>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
<button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
</form>
</div>
@@ -55,7 +132,10 @@
data() {
return {
user_id: null,
- user: {}
+ user: {},
+ packages: [],
+ package_id: null,
+ skus: []
}
},
created() {
@@ -63,6 +143,12 @@
if (this.user_id === 'new') {
// do nothing (for now)
+ axios.get('/api/v4/packages')
+ .then(response => {
+ this.packages = response.data.filter(pkg => !pkg.isDomain)
+ this.package_id = this.packages[0].id
+ })
+ .catch(this.$root.errorHandler)
}
else {
axios.get('/api/v4/users/' + this.user_id)
@@ -72,6 +158,29 @@
this.user.last_name = response.data.settings.last_name
$('#aliases').val(response.data.aliases.join("\n"))
listinput('#aliases')
+
+ axios.get('/api/v4/skus')
+ .then(response => {
+ // "merge" SKUs with user entitlement-SKUs
+ this.skus = response.data
+ .filter(sku => sku.type == 'user')
+ .map(sku => {
+ if (sku.id in this.user.skus) {
+ sku.enabled = true
+ sku.value = this.user.skus[sku.id].count
+ } else if (!sku.readonly) {
+ sku.enabled = false
+ }
+
+ return sku
+ })
+
+ // Update all range inputs (and price)
+ this.$nextTick(() => {
+ $('#user-skus input[type=range]').each((idx, elem) => { this.rangeUpdate(elem) })
+ })
+ })
+ .catch(this.$root.errorHandler)
})
.catch(this.$root.errorHandler)
}
@@ -95,13 +204,21 @@
if (this.user_id !== 'new') {
method = 'put'
location += '/' + this.user_id
+
+ let skus = {}
+ $('#user-skus input[type=checkbox]:checked').each((idx, input) => {
+ let id = $(input).val()
+ let range = $(input).parents('tr').first().find('input[type=range]').val()
+
+ skus[id] = range || 1
+ })
+ this.user.skus = skus
+ } else {
+ this.user.package = this.package_id
}
axios[method](location, this.user)
.then(response => {
- delete this.user.password
- delete this.user.password_confirm
-
if (response.data.status == 'success') {
this.$toastr('success', response.data.message)
}
@@ -111,6 +228,30 @@
this.$router.push({ name: 'users' })
}
})
+ },
+ selectPackage(e) {
+ // Make sure there always is only one package selected
+ $('#user-packages input').prop('checked', false)
+ this.package_id = $(e.target).prop('checked', false).val()
+ },
+ rangeUpdate(e) {
+ let input = $(e.target || e)
+ let value = input.val()
+ let record = input.parents('tr').first()
+ let sku_id = record.find('input[type=checkbox]').val()
+ let sku, i
+
+ for (i = 0; i < this.skus.length; i++) {
+ if (this.skus[i].id == sku_id) {
+ sku = this.skus[i];
+ }
+ }
+
+ // Update the label
+ input.prev().text(value + ' ' + sku.range.unit)
+
+ // Update the price
+ record.find('.price').text(this.$root.price(sku.cost * (value - sku.units_free)) + '/month')
}
}
}
diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue
--- a/src/resources/vue/User/List.vue
+++ b/src/resources/vue/User/List.vue
@@ -45,7 +45,9 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
- <button type="button" class="btn btn-danger modal-action" @click="deleteUser()">Delete</button>
+ <button type="button" class="btn btn-danger modal-action" @click="deleteUser()">
+ <svg-icon icon="trash-alt"></svg-icon> Delete
+ </button>
</div>
</div>
</div>
diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue
--- a/src/resources/vue/User/Profile.vue
+++ b/src/resources/vue/User/Profile.vue
@@ -56,12 +56,12 @@
<input type="password" class="form-control" id="password_confirmation" v-model="profile.password_confirmation">
</div>
</div>
- <button class="btn btn-primary button-submit" type="submit"><svg-icon icon="check"></svg-icon>Submit</button>
+ <button class="btn btn-primary button-submit" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
<router-link
v-if="$root.isController(wallet_id)"
class="btn btn-danger button-delete"
to="/profile/delete" tag="button"
- >Delete account</router-link>
+ ><svg-icon icon="trash-alt"></svg-icon> Delete account</router-link>
</form>
</div>
</div>
diff --git a/src/resources/vue/User/ProfileDelete.vue b/src/resources/vue/User/ProfileDelete.vue
--- a/src/resources/vue/User/ProfileDelete.vue
+++ b/src/resources/vue/User/ProfileDelete.vue
@@ -15,7 +15,9 @@
<p>Also feel free to contact Kolab Now Support at support@kolabnow.com with any questions
or concerns that you may have in this context.</p>
<button class="btn btn-secondary button-cancel" @click="$router.go(-1)">Cancel</button>
- <button class="btn btn-danger button-delete" @click="deleteProfile">Delete account</button>
+ <button class="btn btn-danger button-delete" @click="deleteProfile">
+ <svg-icon icon="trash-alt"></svg-icon> Delete account
+ </button>
</div>
</div>
</div>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -45,6 +45,8 @@
Route::get('domains/{id}/confirm', 'API\DomainsController@confirm');
Route::apiResource('entitlements', API\EntitlementsController::class);
+ Route::apiResource('packages', API\PackagesController::class);
+ Route::apiResource('skus', API\SkusController::class);
Route::apiResource('users', API\UsersController::class);
Route::apiResource('wallets', API\WalletsController::class);
}
diff --git a/src/tests/Browser.php b/src/tests/Browser.php
--- a/src/tests/Browser.php
+++ b/src/tests/Browser.php
@@ -33,6 +33,18 @@
}
/**
+ * Assert Tip element content
+ */
+ public function assertTip($selector, $content)
+ {
+ return $this->click($selector)
+ ->withinBody(function ($browser) use ($content) {
+ $browser->assertSeeIn('div.tooltip .tooltip-inner', $content);
+ })
+ ->click($selector);
+ }
+
+ /**
* Assert specified error page is displayed.
*/
public function assertErrorPage(int $error_code)
diff --git a/src/tests/Browser/Components/QuotaInput.php b/src/tests/Browser/Components/QuotaInput.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Components/QuotaInput.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Tests\Browser\Components;
+
+use Laravel\Dusk\Component as BaseComponent;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+class QuotaInput extends BaseComponent
+{
+ protected $selector;
+
+
+ public function __construct($selector)
+ {
+ $this->selector = trim($selector);
+ }
+
+ /**
+ * Get the root selector for the component.
+ *
+ * @return string
+ */
+ public function selector()
+ {
+ return $this->selector;
+ }
+
+ /**
+ * Assert that the browser page contains the component.
+ *
+ * @param \Laravel\Dusk\Browser $browser
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->waitFor($this->selector() . ' input[type=range]');
+ }
+
+ /**
+ * Assert input value
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser
+ * @param int $value Value in GB
+ *
+ * @return void
+ */
+ public function assertQuotaValue($browser, $value)
+ {
+ $browser->assertValue('@input', $value)
+ ->assertSeeIn('@label', "$value GB");
+ }
+
+ /**
+ * Get the element shortcuts for the component.
+ *
+ * @return array
+ */
+ public function elements()
+ {
+ return [
+ '@label' => 'label',
+ '@input' => 'input',
+ ];
+ }
+
+ /**
+ * Set input value
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser
+ * @param int $value Value in GB
+ *
+ * @return void
+ */
+ public function setQuotaValue($browser, $value)
+ {
+ // Use keyboard because ->value() does not work here
+ $browser->click('@input')->keys('@input', '{home}');
+
+ $num = $value - 2;
+ while ($num > 0) {
+ $browser->keys('@input', '{arrow_right}');
+ $num--;
+ }
+
+ $browser->assertSeeIn('@label', "$value GB");
+ }
+}
diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php
--- a/src/tests/Browser/Pages/UserInfo.php
+++ b/src/tests/Browser/Pages/UserInfo.php
@@ -38,6 +38,8 @@
return [
'@app' => '#app',
'@form' => '#user-info form',
+ '@packages' => '#user-packages',
+ '@skus' => '#user-skus',
];
}
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -2,11 +2,14 @@
namespace Tests\Browser;
+use App\Entitlement;
+use App\Sku;
use App\User;
use App\UserAlias;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\ListInput;
+use Tests\Browser\Components\QuotaInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
@@ -35,6 +38,14 @@
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
+
+ Sku::where('title', 'test')->delete();
+ $storage = Sku::where('title', 'storage')->first();
+ Entitlement::where([
+ ['sku_id', $storage->id],
+ ['entitleable_id', $john->id],
+ ['cost', 25]
+ ])->delete();
}
/**
@@ -49,6 +60,14 @@
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
+ Sku::where('title', 'test')->delete();
+ $storage = Sku::where('title', 'storage')->first();
+ Entitlement::where([
+ ['sku_id', $storage->id],
+ ['entitleable_id', $john->id],
+ ['cost', 25]
+ ])->delete();
+
parent::tearDown();
}
@@ -108,6 +127,17 @@
*/
public function testInfo(): void
{
+ Sku::create([
+ 'title' => 'test',
+ 'name' => 'Test SKU',
+ 'description' => 'The SKU for testing',
+ 'cost' => 666,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Groupware',
+ 'active' => true,
+ ]);
+
$this->browse(function (Browser $browser) {
$browser->on(new UserList())
->click('@table tr:nth-child(2) a')
@@ -203,6 +233,64 @@
$john = User::where('email', 'john@kolab.org')->first();
$alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
+
+ // Test subscriptions
+ $browser->with('@form', function (Browser $browser) {
+ $browser->assertSeeIn('div.row:nth-child(7) label', 'Subscriptions')
+ ->assertVisible('@skus.row:nth-child(7)')
+ ->with('@skus', function ($browser) {
+ $browser->assertElementsCount('tbody tr', 4)
+ // groupware SKU
+ ->assertSeeIn('tbody tr:nth-child(1) td.name', 'Groupware Features')
+ ->assertSeeIn('tbody tr:nth-child(1) td.price', 'CHF 5.55/month')
+ ->assertChecked('tbody tr:nth-child(1) td.selection input')
+ ->assertEnabled('tbody tr:nth-child(1) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(1) td.buttons button',
+ 'Groupware functions like Calendar, Tasks, Notes, etc.'
+ )
+ // Mailbox SKU
+ ->assertSeeIn('tbody tr:nth-child(2) td.name', 'User Mailbox')
+ ->assertSeeIn('tbody tr:nth-child(2) td.price', 'CHF 4.44/month')
+ ->assertChecked('tbody tr:nth-child(2) td.selection input')
+ ->assertDisabled('tbody tr:nth-child(2) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(2) td.buttons button',
+ 'Just a mailbox'
+ )
+ // Storage SKU
+ ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Storage Quota')
+ ->assertSeeIn('tr:nth-child(3) td.price', 'CHF 0.00/month')
+ ->assertChecked('tbody tr:nth-child(3) td.selection input')
+ ->assertDisabled('tbody tr:nth-child(3) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(3) td.buttons button',
+ 'Some wiggle room'
+ )
+ ->with(new QuotaInput('tbody tr:nth-child(3) .range-input'), function ($browser) {
+ $browser->assertQuotaValue(2)->setQuotaValue(3);
+ })
+ ->assertSeeIn('tr:nth-child(3) td.price', 'CHF 0.25/month')
+ // Test SKU
+ ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Test SKU')
+ ->assertSeeIn('tbody tr:nth-child(4) td.price', 'CHF 6.66/month')
+ ->assertNotChecked('tbody tr:nth-child(4) td.selection input')
+ ->assertEnabled('tbody tr:nth-child(4) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(4) td.buttons button',
+ 'The SKU for testing'
+ )
+ ->click('tbody tr:nth-child(4) td.selection input');
+ })
+ ->click('button[type=submit]');
+ })
+ ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('User data updated successfully')
+ ->closeToast();
+ });
+
+ $this->assertUserEntitlements($john, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'test']);
});
}
@@ -239,6 +327,17 @@
->assertValue('div.row:nth-child(5) input[type=password]', '')
->assertSeeIn('div.row:nth-child(6) label', 'Confirm password')
->assertValue('div.row:nth-child(6) input[type=password]', '')
+ ->assertSeeIn('div.row:nth-child(7) label', 'Package')
+ // assert packages list widget, select "Lite Account"
+ ->with('@packages', function ($browser) {
+ $browser->assertElementsCount('tbody tr', 2)
+ ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
+ ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
+ ->assertChecked('tbody tr:nth-child(1) input')
+ ->click('tbody tr:nth-child(2) input')
+ ->assertNotChecked('tbody tr:nth-child(1) input')
+ ->assertChecked('tbody tr:nth-child(2) input');
+ })
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
@@ -300,14 +399,14 @@
->waitForLocation('/users')
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
-// TODO: This will not work until we handle entitlements on user creation
-// $browser->assertElementsCount('tbody tr', 3)
-// ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org');
+ $browser->assertElementsCount('tbody tr', 4)
+ ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
+ $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']);
});
}
diff --git a/src/tests/Feature/BillingTest.php b/src/tests/Feature/BillingTest.php
--- a/src/tests/Feature/BillingTest.php
+++ b/src/tests/Feature/BillingTest.php
@@ -212,6 +212,7 @@
$package = \App\Package::create(
[
'title' => 'kolab-kube',
+ 'name' => 'Kolab for Kuba Fans',
'description' => 'Kolab for Kube fans',
'discount_rate' => 50
]
diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php
--- a/src/tests/Feature/Controller/DomainsTest.php
+++ b/src/tests/Feature/Controller/DomainsTest.php
@@ -37,7 +37,7 @@
*/
public function testConfirm(): void
{
- $sku_domain = Sku::where('title', 'domain')->first();
+ $sku_domain = Sku::where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$user = $this->getTestUser('test1@domainscontroller.com');
@@ -122,7 +122,7 @@
*/
public function testShow(): void
{
- $sku_domain = Sku::where('title', 'domain')->first();
+ $sku_domain = Sku::where('title', 'domain-hosting')->first();
$user = $this->getTestUser('test1@domainscontroller.com');
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW,
diff --git a/src/tests/Feature/Controller/PackagesTest.php b/src/tests/Feature/Controller/PackagesTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/PackagesTest.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Http\Controllers\API\PackagesController;
+use App\Package;
+use Tests\TestCase;
+
+class PackagesTest extends TestCase
+{
+ /**
+ * Test fetching packages list
+ */
+ public function testIndex(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get("api/v4/packages");
+ $response->assertStatus(401);
+
+ $user = $this->getTestUser('john@kolab.org');
+ $package_domain = Package::where('title', 'domain-hosting')->first();
+ $package_kolab = Package::where('title', 'kolab')->first();
+ $package_lite = Package::where('title', 'lite')->first();
+
+ $response = $this->actingAs($user)->get("api/v4/packages");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+
+ $this->assertSame($package_domain->id, $json[0]['id']);
+ $this->assertSame($package_domain->title, $json[0]['title']);
+ $this->assertSame($package_domain->name, $json[0]['name']);
+ $this->assertSame($package_domain->description, $json[0]['description']);
+ $this->assertSame($package_domain->isDomain(), $json[0]['isDomain']);
+ $this->assertSame($package_domain->cost(), $json[0]['cost']);
+
+ $this->assertSame($package_kolab->id, $json[1]['id']);
+ $this->assertSame($package_kolab->title, $json[1]['title']);
+ $this->assertSame($package_kolab->name, $json[1]['name']);
+ $this->assertSame($package_kolab->description, $json[1]['description']);
+ $this->assertSame($package_kolab->isDomain(), $json[1]['isDomain']);
+ $this->assertSame($package_kolab->cost(), $json[1]['cost']);
+
+ $this->assertSame($package_lite->id, $json[2]['id']);
+ $this->assertSame($package_lite->title, $json[2]['title']);
+ $this->assertSame($package_lite->name, $json[2]['name']);
+ $this->assertSame($package_lite->description, $json[2]['description']);
+ $this->assertSame($package_lite->isDomain(), $json[2]['isDomain']);
+ $this->assertSame($package_lite->cost(), $json[2]['cost']);
+ }
+}
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -151,10 +151,10 @@
*/
public function testSignupInitValidInput()
{
- $queue = Queue::fake();
+ Queue::fake();
// Assert that no jobs were pushed...
- $queue->assertNothingPushed();
+ Queue::assertNothingPushed();
$data = [
'email' => 'testuser@external.com',
@@ -171,10 +171,10 @@
$this->assertNotEmpty($json['code']);
// Assert the email sending job was pushed once
- $queue->assertPushed(\App\Jobs\SignupVerificationEmail::class, 1);
+ Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1);
// Assert the job has proper data assigned
- $queue->assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
+ Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
$code = TestCase::getObjectProperty($job, 'code');
return $code->code === $json['code']
@@ -414,8 +414,8 @@
$this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0);
$this->assertNotEmpty($json['access_token']);
- $queue->assertPushed(\App\Jobs\UserCreate::class, 1);
- $queue->assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($data) {
+ Queue::assertPushed(\App\Jobs\UserCreate::class, 1);
+ Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($data) {
$job_user = TestCase::getObjectProperty($job, 'user');
return $job_user->email === \strtolower($data['login'] . '@' . $data['domain']);
@@ -446,7 +446,7 @@
*/
public function testSignupGroupAccount()
{
- $queue = Queue::fake();
+ Queue::fake();
// Initial signup request
$user_data = $data = [
@@ -464,10 +464,10 @@
$this->assertNotEmpty($json['code']);
// Assert the email sending job was pushed once
- $queue->assertPushed(\App\Jobs\SignupVerificationEmail::class, 1);
+ Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1);
// Assert the job has proper data assigned
- $queue->assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
+ Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
$code = TestCase::getObjectProperty($job, 'code');
return $code->code === $json['code']
@@ -516,15 +516,15 @@
$this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0);
$this->assertNotEmpty($result['access_token']);
- $queue->assertPushed(\App\Jobs\DomainCreate::class, 1);
- $queue->assertPushed(\App\Jobs\DomainCreate::class, function ($job) use ($domain) {
+ Queue::assertPushed(\App\Jobs\DomainCreate::class, 1);
+ Queue::assertPushed(\App\Jobs\DomainCreate::class, function ($job) use ($domain) {
$job_domain = TestCase::getObjectProperty($job, 'domain');
return $job_domain->namespace === $domain;
});
- $queue->assertPushed(\App\Jobs\UserCreate::class, 1);
- $queue->assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($data) {
+ Queue::assertPushed(\App\Jobs\UserCreate::class, 1);
+ Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($data) {
$job_user = TestCase::getObjectProperty($job, 'user');
return $job_user->email === $data['login'] . '@' . $data['domain'];
diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Http\Controllers\API\SkusController;
+use App\Sku;
+use Tests\TestCase;
+
+class SkusTest extends TestCase
+{
+ /**
+ * Test fetching SKUs list
+ */
+ public function testIndex(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get("api/v4/skus");
+ $response->assertStatus(401);
+
+ $user = $this->getTestUser('john@kolab.org');
+ $domain_sku = Sku::where('title', 'domain')->first();
+
+ $response = $this->actingAs($user)->get("api/v4/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(7, $json);
+
+ $this->assertSame($domain_sku->id, $json[0]['id']);
+ $this->assertSame($domain_sku->title, $json[0]['title']);
+ $this->assertSame($domain_sku->name, $json[0]['name']);
+ $this->assertSame($domain_sku->description, $json[0]['description']);
+ $this->assertSame($domain_sku->cost, $json[0]['cost']);
+ $this->assertSame($domain_sku->units_free, $json[0]['units_free']);
+ $this->assertSame($domain_sku->period, $json[0]['period']);
+ $this->assertSame($domain_sku->active, $json[0]['active']);
+ $this->assertSame('domain', $json[0]['type']);
+ $this->assertSame('domain', $json[0]['handler']);
+ }
+
+ /**
+ * Test for SkusController::skuElement()
+ */
+ public function testSkuElement(): void
+ {
+ $sku = Sku::where('title', 'storage')->first();
+ $result = $this->invokeMethod(new SkusController(), 'skuElement', [$sku]);
+
+ $this->assertSame($sku->id, $result['id']);
+ $this->assertSame($sku->title, $result['title']);
+ $this->assertSame($sku->name, $result['name']);
+ $this->assertSame($sku->description, $result['description']);
+ $this->assertSame($sku->cost, $result['cost']);
+ $this->assertSame($sku->units_free, $result['units_free']);
+ $this->assertSame($sku->period, $result['period']);
+ $this->assertSame($sku->active, $result['active']);
+ $this->assertSame('user', $result['type']);
+ $this->assertSame('storage', $result['handler']);
+ $this->assertSame($sku->units_free, $result['range']['min']);
+ $this->assertSame($sku->handler_class::MAX_ITEMS, $result['range']['max']);
+ $this->assertSame($sku->handler_class::ITEM_UNIT, $result['range']['unit']);
+ $this->assertTrue($result['readonly']);
+ $this->assertTrue($result['enabled']);
+
+ // Test all SKU types
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -4,6 +4,8 @@
use App\Domain;
use App\Http\Controllers\API\UsersController;
+use App\Package;
+use App\Sku;
use App\User;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
@@ -405,7 +407,7 @@
$userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
// Test getting profile of self
- $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}");
+ $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}");
$json = $response->json();
@@ -415,6 +417,7 @@
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(is_array($json['aliases']));
+ $this->assertSame([], $json['skus']);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
@@ -436,6 +439,17 @@
$response->assertStatus(200);
$response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}");
$response->assertStatus(200);
+
+ $json = $response->json();
+
+ $storage_sku = Sku::where('title', 'storage')->first();
+ $groupware_sku = Sku::where('title', 'groupware')->first();
+ $mailbox_sku = Sku::where('title', 'mailbox')->first();
+
+ $this->assertCount(3, $json['skus']);
+ $this->assertSame(2, $json['skus'][$storage_sku->id]['count']);
+ $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
+ $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
}
/**
@@ -498,16 +512,41 @@
$this->assertCount(2, $json);
$this->assertSame('The specified email is not available.', $json['errors']['email']);
- // Test full user data
+ $package_kolab = \App\Package::where('title', 'kolab')->first();
+ $package_domain = \App\Package::where('title', 'domain-hosting')->first();
+
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'john2.doe2@kolab.org',
- 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org']
+ 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org'],
];
+ // Missing package
+ $response = $this->actingAs($john)->post("/api/v4/users", $post);
+ $json = $response->json();
+
+ $response->assertStatus(422);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Package is required.", $json['errors']['package']);
+ $this->assertCount(2, $json);
+
+ // Invalid package
+ $post['package'] = $package_domain->id;
+ $response = $this->actingAs($john)->post("/api/v4/users", $post);
+ $json = $response->json();
+
+ $response->assertStatus(422);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Invalid package selected.", $json['errors']['package']);
+ $this->assertCount(2, $json);
+
+ // Test full and valid data
+ $post['package'] = $package_kolab->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
@@ -525,9 +564,11 @@
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@kolab.org', $aliases[0]->alias);
$this->assertSame('useralias2@kolab.org', $aliases[1]->alias);
-
- // TODO: Test assigning a package to new user
- // TODO: Test the wallet to which the new user should be assigned to
+ // Assert the new user entitlements
+ $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage']);
+ // Assert the wallet to which the new user should be assigned to
+ $wallet = $user->wallet();
+ $this->assertSame($john->wallets()->first()->id, $wallet->id);
// Test acting as account controller (not owner)
/*
@@ -672,6 +713,62 @@
$response->assertStatus(200);
// TODO: Test error on aliases with invalid/non-existing/other-user's domain
+
+ // Create entitlements and additional user for following tests
+ $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
+ $package_domain = Package::where('title', 'domain-hosting')->first();
+ $package_kolab = Package::where('title', 'kolab')->first();
+ $package_lite = Package::where('title', 'lite')->first();
+ $sku_mailbox = Sku::where('title', 'mailbox')->first();
+ $sku_storage = Sku::where('title', 'storage')->first();
+ $sku_groupware = Sku::where('title', 'groupware')->first();
+
+ $domain = $this->getTestDomain(
+ 'userscontroller.com',
+ [
+ 'status' => Domain::STATUS_NEW,
+ 'type' => Domain::TYPE_EXTERNAL,
+ ]
+ );
+
+ $domain->assignPackage($package_domain, $owner);
+ $owner->assignPackage($package_kolab);
+ $owner->assignPackage($package_lite, $user);
+
+ // Non-controller cannot update his own entitlements
+ $post = ['skus' => []];
+ $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post);
+ $response->assertStatus(422);
+
+ // Test updating entitlements
+ $post = [
+ 'skus' => [
+ $sku_mailbox->id => 1,
+ $sku_storage->id => 3,
+ $sku_groupware->id => 1,
+ ],
+ ];
+
+ $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
+ $response->assertStatus(200);
+
+ $storage_cost = $user->entitlements()
+ ->where('sku_id', $sku_storage->id)
+ ->orderBy('cost')
+ ->pluck('cost')->all();
+
+ $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage']);
+ $this->assertSame([0, 0, 25], $storage_cost);
+ }
+
+ /**
+ * Test UsersController::updateEntitlements()
+ */
+ public function testUpdateEntitlements(): void
+ {
+ // TODO: Test more cases of entitlements update
+ $this->markTestIncomplete();
}
/**
diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php
--- a/src/tests/Feature/DomainTest.php
+++ b/src/tests/Feature/DomainTest.php
@@ -47,8 +47,8 @@
public function testCreateJobs(): void
{
// Fake the queue, assert that no jobs were pushed...
- $queue = Queue::fake();
- $queue->assertNothingPushed();
+ Queue::fake();
+ Queue::assertNothingPushed();
$domain = Domain::create([
'namespace' => 'gmail.com',
@@ -56,9 +56,9 @@
'type' => Domain::TYPE_EXTERNAL,
]);
- $queue->assertPushed(\App\Jobs\DomainCreate::class, 1);
+ Queue::assertPushed(\App\Jobs\DomainCreate::class, 1);
- $queue->assertPushed(
+ Queue::assertPushed(
\App\Jobs\DomainCreate::class,
function ($job) use ($domain) {
$job_domain = TestCase::getObjectProperty($job, 'domain');
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -50,27 +50,35 @@
}
/**
+ * Tests for User::assignSku()
+ */
+ public function testAssignSku(): void
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
* Verify user creation process
*/
public function testUserCreateJob(): void
{
// Fake the queue, assert that no jobs were pushed...
- $queue = Queue::fake();
- $queue->assertNothingPushed();
+ Queue::fake();
+ Queue::assertNothingPushed();
$user = User::create([
'email' => 'user-create-test@' . \config('app.domain')
]);
- $queue->assertPushed(\App\Jobs\UserCreate::class, 1);
- $queue->assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($user) {
+ Queue::assertPushed(\App\Jobs\UserCreate::class, 1);
+ Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($user) {
$job_user = TestCase::getObjectProperty($job, 'user');
return $job_user->id === $user->id
&& $job_user->email === $user->email;
});
- $queue->assertPushedWithChain(\App\Jobs\UserCreate::class, [
+ Queue::assertPushedWithChain(\App\Jobs\UserCreate::class, [
\App\Jobs\UserVerify::class,
]);
/*
@@ -79,8 +87,8 @@
independently (not chained) and make sure there's no race-condition
in status update
- $queue->assertPushed(\App\Jobs\UserVerify::class, 1);
- $queue->assertPushed(\App\Jobs\UserVerify::class, function ($job) use ($user) {
+ Queue::assertPushed(\App\Jobs\UserVerify::class, 1);
+ Queue::assertPushed(\App\Jobs\UserVerify::class, function ($job) use ($user) {
$job_user = TestCase::getObjectProperty($job, 'user');
return $job_user->id === $user->id
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -6,9 +6,24 @@
use App\User;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Queue;
+use PHPUnit\Framework\Assert;
trait TestCaseTrait
{
+ protected function assertUserEntitlements($user, $expected)
+ {
+ // Assert the user entitlements
+ $skus = $user->entitlements()->get()
+ ->map(function ($ent) {
+ return $ent->sku->title;
+ })
+ ->toArray();
+
+ sort($skus);
+
+ Assert::assertSame($expected, $skus);
+ }
+
/**
* Creates the application.
*
@@ -108,7 +123,7 @@
*
* @return mixed Method return.
*/
- public function invokeMethod($object, $methodName, array $parameters = array())
+ protected function invokeMethod($object, $methodName, array $parameters = array())
{
$reflection = new \ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);

File Metadata

Mime Type
text/plain
Expires
Sun, Apr 5, 12:43 PM (8 h, 55 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18833470
Default Alt Text
D1012.1775393008.diff (75 KB)

Event Timeline