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 @@ +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 @@ +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 @@ +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 @@ +
+ +
+ + + + + + + + + + + + + + + + + +
PackagePrice
+ + + {{ pkg.name }} + + {{ $root.price(pkg.cost) + '/month' }} + + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + +
SubscriptionPrice
+ + + {{ sku.name }} +
+ + +
+
+ {{ $root.price(sku.cost) + '/month' }} + + +
+
+
@@ -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 @@ 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 @@ - + Delete account + > Delete account 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 @@

Also feel free to contact Kolab Now Support at support@kolabnow.com with any questions or concerns that you may have in this context.

- + 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 @@ +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 @@ +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 @@ +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);