diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php new file mode 100644 index 00000000..1b7c9bdb --- /dev/null +++ b/src/app/Console/Commands/WalletCharge.php @@ -0,0 +1,58 @@ +expectedCharges(); + + if ($charge > 0) { + $this->info( + "charging wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}" + ); + + $wallet->chargeEntitlements(); + } + } + } +} diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php new file mode 100644 index 00000000..f9d1cee3 --- /dev/null +++ b/src/app/Console/Commands/WalletExpected.php @@ -0,0 +1,55 @@ +expectedCharges(); + + if ($expected > 0) { + $this->info("expect charging wallet {$wallet->id} for user {$wallet->owner->email} with {$expected}"); + } + } + } +} diff --git a/src/app/Domain.php b/src/app/Domain.php index 0e1868c8..b9e37da8 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,338 +1,339 @@ wallets()->get()[0]->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'owner_id' => $user->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, + 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Return list of public+active domain names */ public static function getPublicDomains(): array { $where = sprintf('(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE); return self::whereRaw($where)->get(['namespace'])->map(function ($domain) { return $domain->namespace; })->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $record = \dns_get_record($this->namespace, DNS_ANY); if ($record === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } if (!empty($record)) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 8f108472..dd2d4b71 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,86 +1,91 @@ morphTo(); } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo('App\Sku'); } /** * The owner of this entitlement. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('App\User', 'owner_id', 'id'); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo('App\Wallet'); } } diff --git a/src/app/Handlers/Domain.php b/src/app/Handlers/Domain.php index 1995787e..a5f6f39f 100644 --- a/src/app/Handlers/Domain.php +++ b/src/app/Handlers/Domain.php @@ -1,23 +1,21 @@ sku->active) { \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/DomainHosting.php b/src/app/Handlers/DomainHosting.php index 04743847..c202ae87 100644 --- a/src/app/Handlers/DomainHosting.php +++ b/src/app/Handlers/DomainHosting.php @@ -1,23 +1,21 @@ sku->active) { \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/DomainRegistration.php b/src/app/Handlers/DomainRegistration.php index a822174a..2f94c90e 100644 --- a/src/app/Handlers/DomainRegistration.php +++ b/src/app/Handlers/DomainRegistration.php @@ -1,23 +1,21 @@ sku->active) { \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Groupware.php index 8a51c8d5..9140595c 100644 --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Groupware.php @@ -1,23 +1,21 @@ sku->active) { \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/Mailbox.php b/src/app/Handlers/Mailbox.php index 94a986ef..3ec6cf63 100644 --- a/src/app/Handlers/Mailbox.php +++ b/src/app/Handlers/Mailbox.php @@ -1,43 +1,39 @@ sku->active) { \Log::error("Sku not active"); return false; } /* FIXME: This code prevents from creating initial mailbox SKU on signup of group account, because User::domains() does not return the new domain. Either we make sure to create domain entitlement before mailbox entitlement or make the method here aware of that case or? list($local, $domain) = explode('@', $user->email); $domains = $user->domains(); foreach ($domains as $_domain) { if ($domain == $_domain->namespace) { return true; } } \Log::info("Domain not for user"); */ return true; } } diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php index e8bd80b6..4c77135b 100644 --- a/src/app/Handlers/Resource.php +++ b/src/app/Handlers/Resource.php @@ -1,23 +1,21 @@ sku->active) { \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php index 5b4dd317..e0c4d96f 100644 --- a/src/app/Handlers/SharedFolder.php +++ b/src/app/Handlers/SharedFolder.php @@ -1,23 +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 index 9d7be0a9..5516f9e1 100644 --- a/src/app/Handlers/Storage.php +++ b/src/app/Handlers/Storage.php @@ -1,18 +1,18 @@ {$entitlement->getKeyName()} = $allegedly_unique; break; } } // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet $owner = \App\User::find($entitlement->owner_id); $wallet = \App\Wallet::find($entitlement->wallet_id); if (!$owner) { return false; } if (!$wallet) { return false; } if (!$wallet->owner() == $owner) { if (!$wallet->controllers->contains($owner)) { return false; } } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $owner); if (!$result) { return false; } - - // TODO: Handle the first free unit here? - - // TODO: Execute the Sku handler class or function? - - $wallet->debit($sku->cost); } } diff --git a/src/app/Observers/PackageSkuObserver.php b/src/app/Observers/PackageSkuObserver.php new file mode 100644 index 00000000..7df41b22 --- /dev/null +++ b/src/app/Observers/PackageSkuObserver.php @@ -0,0 +1,19 @@ +package; + $sku = $packageSku->sku; + + $package->skus()->updateExistingPivot( + $sku, + ['cost' => ($sku->cost * (100 - $package->discount_rate)) / 100], + false + ); + } +} diff --git a/src/app/Package.php b/src/app/Package.php index 93cb26c7..4164a9f1 100644 --- a/src/app/Package.php +++ b/src/app/Package.php @@ -1,68 +1,82 @@ skus as $sku) { - $costs += ($sku->pivot->qty - $sku->units_free) * $sku->cost; + $units = $sku->pivot->qty - $sku->units_free; + + if ($units < 0) { + \Log::debug("Package {$this->id} is misconfigured for more free units than qty."); + $units = 0; + } + + $ppu = $sku->cost * ((100 - $this->discount_rate) / 100); + + $costs += $units * $ppu; } return $costs; } public function isDomain() { foreach ($this->skus as $sku) { if ($sku->handler_class::entitleableClass() == \App\Domain::class) { return true; } } return false; } public function skus() { return $this->belongsToMany( 'App\Sku', 'package_skus' )->using('App\PackageSku')->withPivot( ['qty'] ); } } diff --git a/src/app/PackageSku.php b/src/app/PackageSku.php index 3ff615fb..b19fe090 100644 --- a/src/app/PackageSku.php +++ b/src/app/PackageSku.php @@ -1,21 +1,59 @@ 'integer', 'qty' => 'integer' ]; + + /** + * Under this package, how much does this SKU cost? + * + * @return int The costs of this SKU under this package in cents. + */ + public function cost() + { + $costs = 0; + + $units = $this->qty - $this->sku->units_free; + + if ($units < 0) { + \Log::debug( + "Package {$this->package_id} is misconfigured for more free units than qty." + ); + + $units = 0; + } + + $ppu = $this->sku->cost * ((100 - $this->package->discount_rate) / 100); + + $costs += $units * $ppu; + + return $costs; + } + + public function package() + { + return $this->belongsTo('App\Package'); + } + + public function sku() + { + return $this->belongsTo('App\Sku'); + } } diff --git a/src/app/Plan.php b/src/app/Plan.php index b8369bcc..4bc0caf3 100644 --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -1,104 +1,108 @@ 'datetime', 'promo_to' => 'datetime', 'discount_qty' => 'integer', 'discount_rate' => 'integer' ]; /** @var array Translatable properties */ public $translatable = [ 'name', 'description', ]; - + /** + * The list price for this package at the minimum configuration. + * + * @return int The costs in cents. + */ public function cost() { $costs = 0; foreach ($this->packages as $package) { $costs += $package->pivot->cost(); } return $costs; } /** * The relationship to packages. * * The plan contains one or more packages. Each package may have its minimum number (for * billing) or its maximum (to allow topping out "enterprise" customers on a "small business" * plan). * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function packages() { return $this->belongsToMany( 'App\Package', 'plan_packages' )->using('App\PlanPackage')->withPivot( [ 'qty', 'qty_min', 'qty_max', 'discount_qty', 'discount_rate' ] ); } /** * Checks if the plan has any type of domain SKU assigned. * * @return bool */ public function hasDomain(): bool { foreach ($this->packages as $package) { if ($package->isDomain()) { return true; } } return false; } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 2a15f1bd..3b8916eb 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,47 +1,48 @@ sql, implode(', ', $query->bindings))); }); } } } diff --git a/src/app/Sku.php b/src/app/Sku.php index bbe40d0a..04e071e6 100644 --- a/src/app/Sku.php +++ b/src/app/Sku.php @@ -1,46 +1,47 @@ 'integer' ]; protected $fillable = [ 'title', 'description', 'cost', 'units_free', + // persist for annual domain registration 'period', 'handler_class', 'active' ]; /** * List the entitlements that consume this SKU. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } public function packages() { return $this->belongsToMany( 'App\Package', 'package_skus' - )->using('App\PackageSku')->withPivot(['qty']); + )->using('App\PackageSku')->withPivot(['cost', 'qty']); } } diff --git a/src/app/User.php b/src/app/User.php index 822eb9a4..d6d5fcd0 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,448 +1,449 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->get()[0]->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'owner_id' => $this->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, + 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Returns user controlling the current user (or self when it's the account owner) * * @return \App\User A user object */ public function controller(): User { // FIXME: This is most likely not the best way to do this $entitlement = \App\Entitlement::where([ 'entitleable_id' => $this->id, 'entitleable_type' => User::class ])->first(); if ($entitlement && $entitlement->owner_id != $this->id) { return $entitlement->owner; } return $this; } public function assignPlan($plan, $domain = null) { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } $entitlements = Entitlement::where('owner_id', $this->id)->get(); foreach ($entitlements as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { foreach ($wallet->entitlements as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * * @return \App\User User model object if found */ public static function findByEmail(string $email): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $alias = UserAlias::where('alias', $email)->first(); if ($alias) { return $alias->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index f7d18454..545d40f4 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,138 +1,187 @@ 0.00, 'currency' => 'CHF' ]; protected $fillable = [ 'currency' ]; protected $nullable = [ 'description' ]; protected $casts = [ 'balance' => 'float', ]; protected $guarded = ['balance']; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } + public function chargeEntitlements($apply = true) + { + $charges = 0; + + foreach ($this->entitlements()->get()->fresh() as $entitlement) { + // This entitlement has been created less than or equal to 14 days ago (this is at + // maximum the fourteenth 24-hour period). + if ($entitlement->created_at > Carbon::now()->subDays(14)) { + continue; + } + + // This entitlement was created, or billed last, less than a month ago. + if ($entitlement->updated_at > Carbon::now()->subMonths(1)) { + continue; + } + + // created more than a month ago -- was it billed? + if ($entitlement->updated_at <= Carbon::now()->subMonths(1)) { + $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); + + $charges += $entitlement->cost * $diff; + + // if we're in dry-run, you know... + if (!$apply) { + continue; + } + + $entitlement->updated_at = $entitlement->updated_at->copy()->addMonths($diff); + $entitlement->save(); + + $this->debit($entitlement->cost * $diff); + } + } + + return $charges; + } + + + /** + * Calculate the expected charges to this wallet. + * + * @return int + */ + public function expectedCharges() + { + return $this->chargeEntitlements(false); + } + /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Add an amount of pecunia to this wallet's balance. * * @param float $amount The amount of pecunia to add. * * @return Wallet */ public function credit(float $amount) { $this->balance += $amount; $this->save(); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param float $amount The amount of pecunia to deduct. * * @return Wallet */ public function debit(float $amount) { $this->balance -= $amount; $this->save(); return $this; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( 'App\User', // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('App\User', 'user_id', 'id'); } } 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 index 8ed69427..620cc347 100644 --- a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php +++ b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php @@ -1,62 +1,63 @@ string('id', 36)->primary(); $table->string('title', 64); $table->text('description')->nullable(); $table->integer('cost'); $table->smallinteger('units_free')->default('0'); $table->string('period', strlen('monthly'))->default('monthly'); $table->string('handler_class')->nullable(); $table->boolean('active')->default(false); $table->timestamps(); } ); Schema::create( 'entitlements', function (Blueprint $table) { $table->string('id', 36)->primary(); $table->bigInteger('owner_id'); $table->bigInteger('entitleable_id'); $table->string('entitleable_type'); + $table->integer('cost')->default(0)->nullable(); $table->string('wallet_id', 36); $table->string('sku_id', 36); $table->string('description')->nullable(); $table->timestamps(); $table->foreign('sku_id')->references('id')->on('skus')->onDelete('cascade'); $table->foreign('owner_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { // TODO: drop foreign keys first Schema::dropIfExists('entitlements'); Schema::dropIfExists('skus'); } } diff --git a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php index 2e8b60a3..1dee1e15 100644 --- a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php +++ b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php @@ -1,42 +1,45 @@ bigIncrements('id'); $table->string('package_id', 36); $table->string('sku_id', 36); $table->integer('qty')->default(1); + $table->integer('cost')->default(0)->nullable(); + $table->foreign('package_id')->references('id')->on('packages') ->onDelete('cascade')->onUpdate('cascade'); $table->foreign('sku_id')->references('id')->on('skus') ->onDelete('cascade')->onUpdate('cascade'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('package_skus'); } } diff --git a/src/database/migrations/2020_02_11_110959_add_deleted_at.php b/src/database/migrations/2020_02_11_110959_users_add_deleted_at.php similarity index 65% copy from src/database/migrations/2020_02_11_110959_add_deleted_at.php copy to src/database/migrations/2020_02_11_110959_users_add_deleted_at.php index b1551ce3..781e9d3a 100644 --- a/src/database/migrations/2020_02_11_110959_add_deleted_at.php +++ b/src/database/migrations/2020_02_11_110959_users_add_deleted_at.php @@ -1,51 +1,39 @@ softDeletes(); - } - ); - Schema::table( 'users', function (Blueprint $table) { $table->softDeletes(); } ); } /** * Reverse the migrations. * * @return void */ public function down() { - Schema::table( - 'domains', - function (Blueprint $table) { - $table->dropColumn(['deleted_at']); - } - ); Schema::table( 'users', function (Blueprint $table) { $table->dropColumn(['deleted_at']); } ); } } diff --git a/src/database/migrations/2020_02_11_110959_add_deleted_at.php b/src/database/migrations/2020_02_11_110960_domains_add_deleted_at.php similarity index 65% copy from src/database/migrations/2020_02_11_110959_add_deleted_at.php copy to src/database/migrations/2020_02_11_110960_domains_add_deleted_at.php index b1551ce3..1111ce3c 100644 --- a/src/database/migrations/2020_02_11_110959_add_deleted_at.php +++ b/src/database/migrations/2020_02_11_110960_domains_add_deleted_at.php @@ -1,51 +1,39 @@ softDeletes(); } ); - - Schema::table( - 'users', - function (Blueprint $table) { - $table->softDeletes(); - } - ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::table( 'domains', function (Blueprint $table) { $table->dropColumn(['deleted_at']); } ); - Schema::table( - 'users', - function (Blueprint $table) { - $table->dropColumn(['deleted_at']); - } - ); } } diff --git a/src/database/migrations/2020_02_11_110959_add_deleted_at.php b/src/database/migrations/2020_02_26_085835_entitlements_add_deleted_at.php similarity index 61% rename from src/database/migrations/2020_02_11_110959_add_deleted_at.php rename to src/database/migrations/2020_02_26_085835_entitlements_add_deleted_at.php index b1551ce3..1ff58497 100644 --- a/src/database/migrations/2020_02_11_110959_add_deleted_at.php +++ b/src/database/migrations/2020_02_26_085835_entitlements_add_deleted_at.php @@ -1,51 +1,39 @@ softDeletes(); - } - ); - - Schema::table( - 'users', + 'entitlements', function (Blueprint $table) { $table->softDeletes(); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::table( - 'domains', - function (Blueprint $table) { - $table->dropColumn(['deleted_at']); - } - ); - Schema::table( - 'users', + 'entitlements', function (Blueprint $table) { $table->dropColumn(['deleted_at']); } ); } } diff --git a/src/database/seeds/PackageSeeder.php b/src/database/seeds/PackageSeeder.php index da9695d7..264b0f92 100644 --- a/src/database/seeds/PackageSeeder.php +++ b/src/database/seeds/PackageSeeder.php @@ -1,76 +1,80 @@ 'groupware']); + $skuMailbox = Sku::firstOrCreate(['title' => 'mailbox']); + $skuStorage = Sku::firstOrCreate(['title' => 'storage']); + $package = Package::create( [ 'title' => 'kolab', 'description' => 'A fully functional groupware account.', 'discount_rate' => 0 ] ); $skus = [ - Sku::firstOrCreate(['title' => 'mailbox']), - Sku::firstOrCreate(['title' => 'storage']), - Sku::firstOrCreate(['title' => 'groupware']) + $skuMailbox, + $skuGroupware, + $skuStorage ]; $package->skus()->saveMany($skus); // This package contains 2 units of the storage SKU, which just so happens to also // be the number of SKU free units. $package->skus()->updateExistingPivot( - Sku::firstOrCreate(['title' => 'storage']), + $skuStorage, ['qty' => 2], false ); $package = Package::create( [ 'title' => 'lite', 'description' => 'Just mail and no more.', 'discount_rate' => 0 ] ); $skus = [ - Sku::firstOrCreate(['title' => 'mailbox']), - Sku::firstOrCreate(['title' => 'storage']) + $skuMailbox, + $skuStorage ]; $package->skus()->saveMany($skus); $package->skus()->updateExistingPivot( Sku::firstOrCreate(['title' => 'storage']), ['qty' => 2], false ); $package = Package::create( [ 'title' => 'domain-hosting', 'description' => 'Use your own, existing domain.', 'discount_rate' => 0 ] ); $skus = [ Sku::firstOrCreate(['title' => 'domain-hosting']) ]; $package->skus()->saveMany($skus); } } diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php index 75f4d901..b884a956 100644 --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,82 +1,92 @@ 'kolab.org', - 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, + 'status' => Domain::STATUS_NEW + + Domain::STATUS_ACTIVE + + Domain::STATUS_CONFIRMED + + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'name' => 'John Doe', 'email' => 'john@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'phone' => '+1 509-248-1111', ] ); $john->setAliases(['john.doe@kolab.org']); - $user_wallets = $john->wallets()->get(); + $wallet = $john->wallets->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $domain->assignPackage($package_domain, $john); $john->assignPackage($package_kolab); $jack = User::create( [ 'name' => 'Jack Daniels', 'email' => 'jack@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($package_kolab, $jack); + foreach ($john->entitlements as $entitlement) { + $entitlement->created_at = Carbon::now()->subMonths(1); + $entitlement->updated_at = Carbon::now()->subMonths(1); + $entitlement->save(); + } + factory(User::class, 10)->create(); } } diff --git a/src/phpstan.neon b/src/phpstan.neon index 355d82cd..6364c285 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,6 +1,7 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: level: 3 paths: - app/ + - tests/ diff --git a/src/tests/Feature/BillingTest.php b/src/tests/Feature/BillingTest.php new file mode 100644 index 00000000..14b33b7b --- /dev/null +++ b/src/tests/Feature/BillingTest.php @@ -0,0 +1,246 @@ +deleteTestUser('jane@kolabnow.com'); + $this->deleteTestUser('jack@kolabnow.com'); + + \App\Package::where('title', 'kolab-kube')->delete(); + + $this->user = $this->getTestUser('jane@kolabnow.com'); + $this->package = \App\Package::where('title', 'kolab')->first(); + $this->user->assignPackage($this->package); + + $this->wallet = $this->user->wallets->first(); + + $this->wallet_id = $this->wallet->id; + } + + public function tearDown(): void + { + $this->deleteTestUser('jane@kolabnow.com'); + $this->deleteTestUser('jack@kolabnow.com'); + + \App\Package::where('title', 'kolab-kube')->delete(); + + parent::tearDown(); + } + + /** + * Test the expected results for a user that registers and is almost immediately gone. + */ + public function testTouchAndGo(): void + { + $this->assertCount(4, $this->wallet->entitlements); + + $this->assertEquals(0, $this->wallet->expectedCharges()); + + $this->user->delete(); + + $this->assertCount(0, $this->wallet->fresh()->entitlements->where('deleted_at', null)); + + $this->assertCount(4, $this->wallet->entitlements); + } + + /** + * Verify the last day before the end of a full month's trial. + */ + public function testNearFullTrial(): void + { + $this->backdateEntitlements( + $this->wallet->entitlements, + Carbon::now()->subMonths(1)->addDays(1) + ); + + $this->assertEquals(0, $this->wallet->expectedCharges()); + } + + /** + * Verify the exact end of the month's trial. + */ + public function testFullTrial(): void + { + $this->backdateEntitlements($this->wallet->entitlements, Carbon::now()->subMonths(1)); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + } + + /** + * Verify that over-running the trial by a single day causes charges to be incurred. + */ + public function testOutRunTrial(): void + { + $this->backdateEntitlements( + $this->wallet->entitlements, + Carbon::now()->subMonths(1)->subDays(1) + ); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + } + + /** + * Verify additional storage configuration entitlement created 'early' does incur additional + * charges to the wallet. + */ + public function testAddtStorageEarly(): void + { + $this->backdateEntitlements( + $this->wallet->entitlements, + Carbon::now()->subMonths(1)->subDays(1) + ); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + + $sku = \App\Sku::where(['title' => 'storage'])->first(); + + $entitlement = \App\Entitlement::create( + [ + 'owner_id' => $this->user->id, + 'wallet_id' => $this->wallet_id, + 'sku_id' => $sku->id, + 'cost' => $sku->cost, + 'entitleable_id' => $this->user->id, + 'entitleable_type' => \App\User::class + ] + ); + + $this->backdateEntitlements( + [$entitlement], + Carbon::now()->subMonths(1)->subDays(1) + ); + + $this->assertEquals(1024, $this->wallet->expectedCharges()); + } + + /** + * Verify additional storage configuration entitlement created 'late' does not incur additional + * charges to the wallet. + */ + public function testAddtStorageLate(): void + { + $this->backdateEntitlements($this->wallet->entitlements, Carbon::now()->subMonths(1)); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + + $sku = \App\Sku::where(['title' => 'storage'])->first(); + + $entitlement = \App\Entitlement::create( + [ + 'owner_id' => $this->user->id, + 'wallet_id' => $this->wallet_id, + 'sku_id' => $sku->id, + 'cost' => $sku->cost, + 'entitleable_id' => $this->user->id, + 'entitleable_type' => \App\User::class + ] + ); + + $this->backdateEntitlements([$entitlement], Carbon::now()->subDays(14)); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + } + + public function testFifthWeek(): void + { + $targetDateA = Carbon::now()->subWeeks(5); + $targetDateB = $targetDateA->copy()->addMonths(1); + + $this->backdateEntitlements($this->wallet->entitlements, $targetDateA); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + + $this->wallet->chargeEntitlements(); + + $this->assertEquals(-999, $this->wallet->balance); + + foreach ($this->wallet->entitlements()->get() as $entitlement) { + $this->assertTrue($entitlement->created_at->isSameSecond($targetDateA)); + $this->assertTrue($entitlement->updated_at->isSameSecond($targetDateB)); + } + } + + public function testSecondMonth(): void + { + $this->backdateEntitlements($this->wallet->entitlements, Carbon::now()->subMonths(2)); + + $this->assertCount(4, $this->wallet->entitlements); + + $this->assertEquals(1998, $this->wallet->expectedCharges()); + + $sku = \App\Sku::where(['title' => 'storage'])->first(); + + $entitlement = \App\Entitlement::create( + [ + 'owner_id' => $this->user->id, + 'entitleable_id' => $this->user->id, + 'entitleable_type' => \App\User::class, + 'cost' => $sku->cost, + 'sku_id' => $sku->id, + 'wallet_id' => $this->wallet_id + ] + ); + + $this->backdateEntitlements([$entitlement], Carbon::now()->subMonths(1)); + + $this->assertEquals(2023, $this->wallet->expectedCharges()); + } + + public function testWithDiscount(): void + { + $package = \App\Package::create( + [ + 'title' => 'kolab-kube', + 'description' => 'Kolab for Kube fans', + 'discount_rate' => 50 + ] + ); + + $skus = [ + \App\Sku::firstOrCreate(['title' => 'mailbox']), + \App\Sku::firstOrCreate(['title' => 'storage']), + \App\Sku::firstOrCreate(['title' => 'groupware']) + ]; + + $package->skus()->saveMany($skus); + + $package->skus()->updateExistingPivot( + \App\Sku::firstOrCreate(['title' => 'storage']), + ['qty' => 2], + false + ); + + $user = $this->getTestUser('jack@kolabnow.com'); + + $user->assignPackage($package); + + $wallet = $user->wallets->first(); + + $wallet_id = $wallet->id; + + $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonths(1)); + + $this->assertEquals(500, $wallet->expectedCharges()); + } +} diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index 30cb7384..a0f4dc9f 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,601 +1,601 @@ domain = $this->getPublicDomain(); $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); parent::tearDown(); } /** * Return a public domain for signup tests */ private function getPublicDomain(): string { if (!$this->domain) { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $this->domain = reset($public_domains); if (empty($this->domain)) { $this->domain = 'signup-domain.com'; Domain::create([ 'namespace' => $this->domain, 'status' => Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); } } return $this->domain; } /** * Test fetching plans for signup * * @return void */ public function testSignupPlans() { // Note: this uses plans that already have been seeded into the DB $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertArrayHasKey('title', $json['plans'][0]); $this->assertArrayHasKey('name', $json['plans'][0]); $this->assertArrayHasKey('description', $json['plans'][0]); $this->assertArrayHasKey('button', $json['plans'][0]); } /** * Test signup initialization with invalid input * * @return void */ public function testSignupInitInvalidInput() { // Empty input data $data = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'name' => 'Signup User', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // TODO: Test phone validation } /** * Test signup initialization with valid input * * @return array */ public function testSignupInitValidInput() { - Queue::fake(); + $queue = Queue::fake(); // Assert that no jobs were pushed... - Queue::assertNothingPushed(); + $queue->assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'name' => 'Signup User', 'plan' => 'individual', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $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'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['name'] === $data['name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'name' => $data['name'], 'plan' => $data['plan'], ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput * @return void */ public function testSignupVerifyInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test signup code verification with valid input * * @depends testSignupInitValidInput * * @return array */ public function testSignupVerifyValidInput(array $result) { $code = SignupCode::find($result['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['name'], $json['name']); $this->assertSame(false, $json['is_domain']); $this->assertTrue(is_array($json['domains']) && !empty($json['domains'])); return $result; } /** * Test last signup step with invalid input * * @depends testSignupVerifyValidInput * @return void */ public function testSignupInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Passwords do not match and missing domain $data = [ 'login' => 'test', 'password' => 'test', 'password_confirmation' => 'test2', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Login too short $data = [ 'login' => '1', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); // Missing codes $data = [ 'login' => 'login-valid', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Valid code, invalid login $code = SignupCode::find($result['code']); $data = [ 'login' => 'żżżżżż', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput * @return void */ public function testSignupValidInput(array $result) { - Queue::fake(); + $queue = Queue::fake(); $domain = $this->getPublicDomain(); $identity = \strtolower('SignupLogin@') . $domain; $code = SignupCode::find($result['code']); $data = [ 'login' => 'SignupLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(4, $json); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $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']); }); // Check if the code has been removed $this->assertNull(SignupCode::where('code', $result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); $this->assertSame($result['name'], $user->name); // Check external email in user settings $this->assertSame($result['email'], $user->getSetting('external_email')); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup for a group (custom domain) account * * @return void */ public function testSignupGroupAccount() { - Queue::fake(); + $queue = Queue::fake(); // Initial signup request $user_data = $data = [ 'email' => 'testuser@external.com', 'name' => 'Signup User', 'plan' => 'group', ]; $response = $this->withoutMiddleware()->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $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'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['name'] === $data['name']; }); // Verify the code $code = SignupCode::find($json['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $result = $response->json(); $response->assertStatus(200); $this->assertCount(5, $result); $this->assertSame('success', $result['status']); $this->assertSame($user_data['email'], $result['email']); $this->assertSame($user_data['name'], $result['name']); $this->assertSame(true, $result['is_domain']); $this->assertSame([], $result['domains']); // Final signup request $login = 'admin'; $domain = 'external.com'; $data = [ 'login' => $login, 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $result = $response->json(); $response->assertStatus(200); $this->assertCount(4, $result); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $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']; }); // Check if the code has been removed $this->assertNull(SignupCode::find($code->id)); // Check if the user has been created $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); $this->assertSame($user_data['name'], $user->name); // Check domain record // Check external email in user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * List of login/domain validation cases for testValidateLogin() * * @return array Arguments for testValidateLogin() */ public function dataValidateLogin(): array { $domain = $this->getPublicDomain(); return [ // Individual account ['', $domain, false, ['login' => 'The login field is required.']], ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']], ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']], ['test.test', $domain, false, null], ['test_test', $domain, false, null], ['test-test', $domain, false, null], ['admin', $domain, false, ['login' => 'The specified login is not available.']], ['administrator', $domain, false, ['login' => 'The specified login is not available.']], ['sales', $domain, false, ['login' => 'The specified login is not available.']], ['root', $domain, false, ['login' => 'The specified login is not available.']], // TODO existing (public domain) user // ['signuplogin', $domain, false, ['login' => 'The specified login is not available.']], // Domain account ['admin', 'kolabsys.com', true, null], ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']], ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']], // existing custom domain ['jack', 'kolab.org', true, ['domain' => 'The specified domain is not available.']], ]; } /** * Signup login/domain validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateLogin */ public function testValidateLogin($login, $domain, $external, $expected_result): void { $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame($expected_result, $result); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index e7a9e5a4..455bcc28 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,166 +1,166 @@ domains as $domain) { $this->deleteTestDomain($domain); } } public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } parent::tearDown(); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... - Queue::fake(); - Queue::assertNothingPushed(); + $queue = Queue::fake(); + $queue->assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, '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'); return $job_domain->id === $domain->id && $job_domain->namespace === $domain->namespace; } ); $job = new \App\Jobs\DomainCreate($domain); $job->handle(); } /** * Tests getPublicDomains() method */ public function testGetPublicDomains(): void { $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); - Queue::fake(); + $queue = Queue::fake(); $domain = Domain::create([ 'namespace' => 'public-active.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); // Public but non-active domain should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $domain = Domain::where('namespace', 'public-active.com')->first(); $domain->status = Domain::STATUS_ACTIVE; $domain->save(); // Public and active domain should be returned $public_domains = Domain::getPublicDomains(); $this->assertContains('public-active.com', $public_domains); } /** * Test domain (ownership) confirmation * * @group dns */ public function testConfirm(): void { /* DNS records for positive and negative tests - kolab.org: ci-success-cname A 212.103.80.148 ci-success-cname MX 10 mx01.kolabnow.com. ci-success-cname TXT "v=spf1 mx -all" kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname ci-failure-cname A 212.103.80.148 ci-failure-cname MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname ci-success-txt A 212.103.80.148 ci-success-txt MX 10 mx01.kolabnow.com. ci-success-txt TXT "v=spf1 mx -all" ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-txt A 212.103.80.148 ci-failure-txt MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-none A 212.103.80.148 ci-failure-none MX 10 mx01.kolabnow.com. */ - Queue::fake(); + $queue = Queue::fake(); $domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]; $domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 217ecdea..0f646da5 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,128 +1,108 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); } public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); parent::tearDown(); } /** * Tests for User::AddEntitlement() */ public function testUserAddEntitlement(): void { - $sku_domain = Sku::firstOrCreate(['title' => 'domain']); - $sku_mailbox = Sku::firstOrCreate(['title' => 'mailbox']); + $package_domain = Package::where('title', 'domain-hosting')->first(); + $package_kolab = Package::where('title', 'kolab')->first(); + + $sku_domain = Sku::where('title', 'domain-hosting')->first(); + $sku_mailbox = Sku::where('title', 'mailbox')->first(); + $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); + $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); - $wallet = $owner->wallets()->first(); + $domain->assignPackage($package_domain, $owner); - $entitlement_own_mailbox = new Entitlement( - [ - 'owner_id' => $owner->id, - 'entitleable_id' => $owner->id, - 'entitleable_type' => User::class, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku_mailbox->id, - 'description' => "Owner Mailbox Entitlement Test" - ] - ); + $owner->assignPackage($package_kolab); + $owner->assignPackage($package_kolab, $user); - $entitlement_domain = new Entitlement( - [ - 'owner_id' => $owner->id, - 'entitleable_id' => $domain->id, - 'entitleable_type' => Domain::class, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku_domain->id, - 'description' => "User Domain Entitlement Test" - ] - ); + $wallet = $owner->wallets->first(); - $entitlement_mailbox = new Entitlement( - [ - 'owner_id' => $owner->id, - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku_mailbox->id, - 'description' => "User Mailbox Entitlement Test" - ] - ); + $this->assertCount(4, $owner->entitlements()->get()); + $this->assertCount(1, $sku_domain->entitlements()->where('owner_id', $owner->id)->get()); + $this->assertCount(2, $sku_mailbox->entitlements()->where('owner_id', $owner->id)->get()); + $this->assertCount(9, $wallet->entitlements); + + $this->backdateEntitlements($owner->entitlements, Carbon::now()->subMonths(1)); - $owner->addEntitlement($entitlement_own_mailbox); - $owner->addEntitlement($entitlement_domain); - $owner->addEntitlement($entitlement_mailbox); + $wallet->chargeEntitlements(); - $this->assertTrue($owner->entitlements()->count() == 3); - $this->assertTrue($sku_domain->entitlements()->where('owner_id', $owner->id)->count() == 1); - $this->assertTrue($sku_mailbox->entitlements()->where('owner_id', $owner->id)->count() == 2); - $this->assertTrue($wallet->entitlements()->count() == 3); - $this->assertTrue($wallet->fresh()->balance < 0.00); + $this->assertTrue($wallet->fresh()->balance < 0); } public function testAddExistingEntitlement(): void { $this->markTestIncomplete(); } public function testEntitlementFunctions(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $this->assertNotNull($wallet); $sku = \App\Sku::where('title', 'mailbox')->first(); $this->assertNotNull($sku); $entitlement = Entitlement::where('owner_id', $user->id)->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $e_sku = $entitlement->sku; $this->assertSame($sku->id, $e_sku->id); $e_wallet = $entitlement->wallet; $this->assertSame($wallet->id, $e_wallet->id); $e_owner = $entitlement->owner; $this->assertEquals($user->id, $e_owner->id); $e_entitleable = $entitlement->entitleable; $this->assertEquals($user->id, $e_entitleable->id); $this->assertTrue($e_entitleable instanceof \App\User); } } diff --git a/src/tests/Feature/Jobs/UserVerifyTest.php b/src/tests/Feature/Jobs/UserVerifyTest.php index 8bb3ee90..13cc00b8 100644 --- a/src/tests/Feature/Jobs/UserVerifyTest.php +++ b/src/tests/Feature/Jobs/UserVerifyTest.php @@ -1,31 +1,53 @@ deleteTestUser('jane@kolabnow.com'); + } + + public function tearDown(): void + { + $this->deleteTestUser('jane@kolabnow.com'); + + parent::tearDown(); + } + /** * Test job handle * * @group imap */ public function testHandle(): void { - $user = $this->getTestUser('john@kolab.org'); - $user->status ^= User::STATUS_IMAP_READY; - $user->save(); + Queue::fake(); + + $user = $this->getTestUser('jane@kolabnow.com'); + + // This is a valid assertion in a feature, not functional test environment. + $this->assertFalse($user->isImapReady()); + $this->assertFalse($user->isLdapReady()); + + $job = new UserCreate($user); + $job->handle(); $this->assertFalse($user->isImapReady()); + $this->assertTrue($user->isLdapReady()); $job = new UserVerify($user); $job->handle(); $this->assertTrue($user->fresh()->isImapReady()); } } diff --git a/src/tests/Feature/SignupCodeTest.php b/src/tests/Feature/SignupCodeTest.php index c74e609a..d285aa35 100644 --- a/src/tests/Feature/SignupCodeTest.php +++ b/src/tests/Feature/SignupCodeTest.php @@ -1,53 +1,54 @@ [ 'email' => 'User@email.org', 'name' => 'User Name', ] ]; - $now = new \DateTime('now'); + $now = Carbon::now(); $code = SignupCode::create($data); $this->assertFalse($code->isExpired()); $this->assertTrue(strlen($code->code) === SignupCode::CODE_LENGTH); $this->assertTrue( strlen($code->short_code) === env( 'VERIFICATION_CODE_LENGTH', SignupCode::SHORTCODE_LENGTH ) ); $this->assertSame($data['data'], $code->data); - $this->assertInstanceOf(\DateTime::class, $code->expires_at); + $this->assertInstanceOf(Carbon::class, $code->expires_at); $this->assertSame( env('SIGNUP_CODE_EXPIRY', SignupCode::CODE_EXP_HOURS), - $code->expires_at->diff($now)->h + 1 + $code->expires_at->diffInHours($now) + 1 ); $inst = SignupCode::find($code->code); $this->assertInstanceOf(SignupCode::class, $inst); $this->assertSame($inst->code, $code->code); } } diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php index 70aece60..663983d6 100644 --- a/src/tests/Feature/SkuTest.php +++ b/src/tests/Feature/SkuTest.php @@ -1,243 +1,94 @@ deleteTestUser('sku-test-user@custom-domain.com'); - $this->deleteTestDomain('custom-domain.com'); + $this->deleteTestUser('jane@kolabnow.com'); } public function tearDown(): void { - $this->deleteTestUser('sku-test-user@custom-domain.com'); - $this->deleteTestDomain('custom-domain.com'); + $this->deleteTestUser('jane@kolabnow.com'); parent::tearDown(); } - /** - * Tests for Sku::registerEntitlements() - */ - public function testRegisterEntitlement(): void + public function testPackageEntitlements(): void { - // TODO: This test depends on seeded SKUs, but probably should not - $domain = $this->getTestDomain( - 'custom-domain.com', - [ - 'status' => Domain::STATUS_NEW, - 'type' => Domain::TYPE_EXTERNAL, - ] - ); - - \Log::debug(var_export($domain->toArray(), true)); + $user = $this->getTestUser('jane@kolabnow.com'); - $user = $this->getTestUser('sku-test-user@custom-domain.com'); $wallet = $user->wallets()->first(); - // \App\Handlers\Mailbox SKU - // Note, we're testing mailbox SKU before domain SKU as it may potentially fail in that - // order - $sku = Sku::where('title', 'mailbox')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class - ] - ); + $package = Package::where('title', 'lite')->first(); - $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get(); - $wallet->refresh(); - - if ($sku->active) { - $balance = -$sku->cost; - $this->assertCount(1, $entitlements); - $this->assertEquals($user->id, $entitlements[0]->entitleable_id); - $this->assertSame( - Handlers\Mailbox::entitleableClass(), - $entitlements[0]->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } - - $this->assertEquals($balance, $wallet->balance); - - // \App\Handlers\Domain SKU - $sku = Sku::where('title', 'domain')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $domain->id, - 'entitleable_type' => Domain::class - ] - ); - - $entitlements = $sku->entitlements->where('owner_id', $user->id); - - foreach ($entitlements as $entitlement) { - \Log::debug(var_export($entitlement->toArray(), true)); - } - - $wallet->refresh(); - - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - - $_domain = Domain::find($entitlements->first()->entitleable_id); - - $this->assertEquals( - $domain->id, - $entitlements->first()->entitleable_id, - var_export($_domain->toArray(), true) - ); - - $this->assertSame( - Handlers\Domain::entitleableClass(), - $entitlements->first()->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } - - $this->assertEquals($balance, $wallet->balance); - - // \App\Handlers\DomainRegistration SKU - $sku = Sku::where('title', 'domain-registration')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $user->wallets()->get()[0]->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $domain->id, - 'entitleable_type' => Domain::class - ] - ); + $sku_mailbox = Sku::where('title', 'mailbox')->first(); + $sku_storage = Sku::where('title', 'storage')->first(); - $entitlements = $sku->entitlements->where('owner_id', $user->id); - $wallet->refresh(); + $user = $user->assignPackage($package); - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - $this->assertEquals($domain->id, $entitlements->first()->entitleable_id); - $this->assertSame( - Handlers\DomainRegistration::entitleableClass(), - $entitlements->first()->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } + $this->backdateEntitlements($user->fresh()->entitlements, Carbon::now()->subMonths(1)); - $this->assertEquals($balance, $wallet->balance); + $wallet->chargeEntitlements(); - // \App\Handlers\DomainHosting SKU - $sku = Sku::where('title', 'domain-hosting')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $domain->id, - 'entitleable_type' => Domain::class - ] - ); + $this->assertTrue($wallet->balance < 0); + } - $entitlements = $sku->entitlements->where('owner_id', $user->id); - $wallet->refresh(); - - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - $this->assertEquals($domain->id, $entitlements->first()->entitleable_id); - $this->assertSame( - Handlers\DomainHosting::entitleableClass(), - $entitlements->first()->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } - - $this->assertEquals($balance, $wallet->balance); - - // \App\Handlers\Groupware SKU - $sku = Sku::where('title', 'groupware')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $user->wallets()->get()[0]->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class - ] - ); + public function testSkuEntitlements(): void + { + $this->assertCount(2, Sku::where('title', 'mailbox')->first()->entitlements); + } - $entitlements = $sku->entitlements->where('owner_id', $user->id); - $wallet->refresh(); + public function testSkuPackages(): void + { + $this->assertCount(2, Sku::where('title', 'mailbox')->first()->packages); + } - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - $this->assertEquals($user->id, $entitlements->first()->entitleable_id); - $this->assertSame( - Handlers\Mailbox::entitleableClass(), - $entitlements->first()->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } + public function testSkuHandlerDomainHosting(): void + { + $sku = Sku::where('title', 'domain-hosting')->first(); - $this->assertEquals($balance, $wallet->balance); + $entitlement = $sku->entitlements->first(); - // \App\Handlers\Storage SKU - $sku = Sku::where('title', 'storage')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class - ] + $this->assertSame( + Handlers\DomainHosting::entitleableClass(), + $entitlement->entitleable_type ); + } - $entitlements = $sku->entitlements->where('owner_id', $user->id); - $wallet->refresh(); + public function testSkuHandlerMailbox(): void + { + $sku = Sku::where('title', 'mailbox')->first(); - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - } else { - $this->assertCount(0, $entitlements); - } + $entitlement = $sku->entitlements->first(); - $this->assertEquals($balance, $wallet->balance); + $this->assertSame( + Handlers\Mailbox::entitleableClass(), + $entitlement->entitleable_type + ); } - public function testSkuPackages(): void + public function testSkuHandlerStorage(): void { - $sku = Sku::where('title', 'mailbox')->first(); + $sku = Sku::where('title', 'storage')->first(); - $packages = $sku->packages; + $entitlement = $sku->entitlements->first(); - $this->assertCount(2, $packages); + $this->assertSame( + Handlers\Storage::entitleableClass(), + $entitlement->entitleable_type + ); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 70d16970..baf379dc 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,263 +1,263 @@ deleteTestUser('user-create-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('userdeletejob@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('user-create-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('userdeletejob@kolabnow.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Verify user creation process */ public function testUserCreateJob(): void { // Fake the queue, assert that no jobs were pushed... - Queue::fake(); - Queue::assertNothingPushed(); + $queue = 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, ]); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs 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 && $job_user->email === $user->email; }); */ } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testListUserAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } /** * Tests for User::controller() */ public function testController(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertSame($john->id, $john->controller()->id); $jack = $this->getTestUser('jack@kolab.org'); $this->assertSame($john->id, $jack->controller()->id); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $domains); $this->assertNotContains('kolab.org', $domains); } public function testUserQuota(): void { $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 2); } /** * Test user deletion */ public function testUserDelete(): void { $user = $this->getTestUser('userdeletejob@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $user->delete(); $job = new \App\Jobs\UserDelete($id); $job->handle(); $user->forceDelete(); $entitlements = \App\Entitlement::where('owner_id', 'id')->get(); $this->assertCount(0, $entitlements); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertCount(0, $user->aliases->all()); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); $this->assertCount(0, $user->aliases()->get()); // TODO: Test that the changes are propagated to ldap } /** * Tests for UserSettingsTrait::setSettings() */ public function testSetSettings(): void { $this->markTestIncomplete(); } } diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php index 66fd9cd1..d130ea3b 100644 --- a/src/tests/Feature/VerificationCodeTest.php +++ b/src/tests/Feature/VerificationCodeTest.php @@ -1,58 +1,59 @@ deleteTestUser('UserAccountA@UserAccount.com'); } public function tearDown(): void { $this->deleteTestUser('UserAccountA@UserAccount.com'); parent::tearDown(); } /** * Test VerificationCode creation */ public function testVerificationCodeCreate(): void { $user = $this->getTestUser('UserAccountA@UserAccount.com'); $data = [ 'user_id' => $user->id, 'mode' => 'password-reset', ]; $now = new \DateTime('now'); $code = VerificationCode::create($data); $code_length = env('VERIFICATION_CODE_LENGTH', VerificationCode::SHORTCODE_LENGTH); $code_exp_hrs = env('VERIFICATION_CODE_EXPIRY', VerificationCode::CODE_EXP_HOURS); $this->assertFalse($code->isExpired()); $this->assertTrue(strlen($code->code) === VerificationCode::CODE_LENGTH); $this->assertTrue(strlen($code->short_code) === $code_length); $this->assertSame($data['mode'], $code->mode); $this->assertEquals($user->id, $code->user->id); $this->assertInstanceOf(\DateTime::class, $code->expires_at); + $this->assertSame($code_exp_hrs, $code->expires_at->diff($now)->h + 1); $inst = VerificationCode::find($code->code); $this->assertInstanceOf(VerificationCode::class, $inst); $this->assertSame($inst->code, $code->code); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index 84059ed4..d85e563d 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,178 +1,175 @@ users as $user) { $this->deleteTestUser($user); } } public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } parent::tearDown(); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); - $this->assertTrue($user->wallets()->count() == 1); + $this->assertCount(1, $user->wallets); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); - $this->assertTrue($user->wallets()->count() >= 2); + $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { - $this->assertTrue($wallet->balance === 0.00); + $this->assertEquals(0, $wallet->balance); } ); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { - $wallet->credit(1.00)->save(); + $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); - $this->assertTrue($user->wallets()->count() == 1); + $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); } /** * Verify a wallet can be assigned a controller. */ public function testAddWalletController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); - $this->assertTrue( - $userB->accounts()->count() == 1, - "number of accounts (1 expected): {$userB->accounts()->count()}" - ); + $this->assertCount(1, $userB->accounts); - $aWallet = $userA->wallets()->get(); - $bAccount = $userB->accounts()->get(); + $aWallet = $userA->wallets()->first(); + $bAccount = $userB->accounts()->first(); - $this->assertTrue($bAccount[0]->id === $aWallet[0]->id); + $this->assertTrue($bAccount->id === $aWallet->id); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); - $this->assertTrue($userB->accounts()->count() == 0); + $this->assertCount(0, $userB->accounts); } } diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index 499d8cb2..fc9ce538 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -1,94 +1,116 @@ created_at = $targetDate; + $entitlement->updated_at = $targetDate; + $entitlement->save(); + } + } + protected function deleteTestDomain($name) { Queue::fake(); + $domain = Domain::withTrashed()->where('namespace', $name)->first(); + if (!$domain) { return; } $job = new \App\Jobs\DomainDelete($domain); $job->handle(); $domain->forceDelete(); } protected function deleteTestUser($email) { Queue::fake(); + $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\UserDelete($user->id); $job->handle(); $user->forceDelete(); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); - return User::firstOrCreate(['email' => $email], $attrib); + $user = User::withTrashed()->where('email', $email)->first(); + + if (!$user) { + return User::firstOrCreate(['email' => $email], $attrib); + } + + if ($user->deleted_at) { + $user->restore(); + } + + return $user; } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ public function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } }