Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117908201
D1012.1775393008.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
75 KB
Referenced Files
None
Subscribers
None
D1012.1775393008.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D1012: User entitlements management
Attached
Detach File
Event Timeline