diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
index 3b84e2b3..f68dca85 100644
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -1,127 +1,125 @@
user();
$response = V4\UsersController::userResponse($user);
+ if (!empty(request()->input('refresh_token'))) {
+ // @phpstan-ignore-next-line
+ return $this->respondWithToken(Auth::guard()->refresh(), $response);
+ }
+
return response()->json($response);
}
/**
* Helper method for other controllers with user auto-logon
* functionality
*
* @param \App\User $user User model object
*/
public static function logonResponse(User $user)
{
// @phpstan-ignore-next-line
$token = Auth::guard()->login($user);
- return response()->json([
- 'status' => 'success',
- 'access_token' => $token,
- 'token_type' => 'bearer',
- // @phpstan-ignore-next-line
- 'expires_in' => Auth::guard()->factory()->getTTL() * 60,
- ]);
+ return self::respondWithToken($token, ['status' => 'success']);
}
/**
* Get a JWT token via given credentials.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse
*/
public function login(Request $request)
{
// TODO: Redirect to dashboard if authenticated.
$v = Validator::make(
$request->all(),
[
'email' => 'required|min:2',
'password' => 'required|min:4',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$credentials = $request->only('email', 'password');
if ($token = Auth::guard()->attempt($credentials)) {
$sf = new \App\Auth\SecondFactor(Auth::guard()->user());
if ($response = $sf->requestHandler($request)) {
return $response;
}
return $this->respondWithToken($token);
}
return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401);
}
/**
* Log the user out (Invalidate the token)
*
* @return \Illuminate\Http\JsonResponse
*/
public function logout()
{
Auth::guard()->logout();
return response()->json([
'status' => 'success',
'message' => __('auth.logoutsuccess')
]);
}
/**
* Refresh a token.
*
* @return \Illuminate\Http\JsonResponse
*/
public function refresh()
{
// @phpstan-ignore-next-line
return $this->respondWithToken(Auth::guard()->refresh());
}
/**
* Get the token array structure.
*
- * @param string $token Respond with this token.
+ * @param string $token Respond with this token.
+ * @param array $response Additional response data
*
* @return \Illuminate\Http\JsonResponse
*/
- protected function respondWithToken($token)
+ protected static function respondWithToken($token, array $response = [])
{
- return response()->json(
- [
- 'access_token' => $token,
- 'token_type' => 'bearer',
- // @phpstan-ignore-next-line
- 'expires_in' => Auth::guard()->factory()->getTTL() * 60
- ]
- );
+ $response['access_token'] = $token;
+ $response['token_type'] = 'bearer';
+ // @phpstan-ignore-next-line
+ $response['expires_in'] = Auth::guard()->factory()->getTTL() * 60;
+
+ return response()->json($response);
}
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
index 4c49cecb..c5ab4ed4 100644
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -1,456 +1,461 @@
tag
*/
public function customerLink(Wallet $wallet): ?string
{
- $customer_id = self::mollieCustomerId($wallet);
+ $customer_id = self::mollieCustomerId($wallet, false);
+
+ if (!$customer_id) {
+ return null;
+ }
return sprintf(
'%s',
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - description: Operation desc.
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Mollie, if not yet done
- $customer_id = self::mollieCustomerId($wallet);
+ $customer_id = self::mollieCustomerId($wallet, true);
$request = [
'amount' => [
'currency' => $payment['currency'],
'value' => '0.00',
],
'customerId' => $customer_id,
'sequenceType' => 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'redirectUrl' => \url('/wallet'),
'locale' => 'en_US',
// 'method' => 'creditcard',
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
if ($response->mandateId) {
$wallet->setSetting('mollie_mandate_id', $response->mandateId);
}
return [
'id' => $response->id,
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Revoke the auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
// Revoke the mandate on Mollie
if ($mandate) {
$mandate->revoke();
$wallet->setSetting('mollie_mandate_id', null);
}
return true;
}
/**
* Get a auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
if (empty($mandate)) {
return null;
}
$result = [
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
'method' => self::paymentMethod($mandate, 'Unknown method')
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'mollie';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == self::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Mollie, if not yet done
- $customer_id = self::mollieCustomerId($wallet);
+ $customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
'value' => sprintf('%.2f', $payment['amount'] / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
// 'method' => 'creditcard',
'redirectUrl' => \url('/wallet') // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
// billingEmail - for bank transfers, Przelewy24, but not creditcard
// billingAddress (it is a structured field not just text)
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Operation identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::mollieMandate($wallet);
if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
return null;
}
- $customer_id = self::mollieCustomerId($wallet);
+ $customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
'value' => sprintf('%.2f', $payment['amount'] / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
// 'method' => 'creditcard',
'mandateId' => $mandate->id
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
DB::beginTransaction();
$payment = $this->storePayment($payment, $wallet->id);
// Mollie can return 'paid' status immediately, so we don't
// have to wait for the webhook. What's more, the webhook would ignore
// the payment because it will be marked as paid before the webhook.
// Let's handle paid status here too.
if ($response->isPaid()) {
self::creditPayment($payment, $response);
$notify = true;
} elseif ($response->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $response->id));
// Disable the mandate
$wallet->setSetting('mandate_disabled', 1);
$notify = true;
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
$payment_id = \request()->input('id');
if (empty($payment_id)) {
return 200;
}
$payment = Payment::find($payment_id);
if (empty($payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
// Get the payment details from Mollie
$mollie_payment = mollie()->payments()->get($payment_id);
if (empty($mollie_payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
if ($mollie_payment->isPaid()) {
if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) {
// The payment is paid and isn't refunded or charged back.
// Update the balance, if it wasn't already
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
$credit = true;
$notify = $payment->type == self::TYPE_RECURRING;
}
} elseif ($mollie_payment->hasRefunds()) {
// The payment has been (partially) refunded.
// The status of the payment is still "paid"
// TODO: Update balance
} elseif ($mollie_payment->hasChargebacks()) {
// The payment has been (partially) charged back.
// The status of the payment is still "paid"
// TODO: Update balance
}
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
// Disable the mandate
if ($payment->type == self::TYPE_RECURRING) {
$notify = true;
$payment->wallet->setSetting('mandate_disabled', 1);
}
}
DB::beginTransaction();
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after
// recivied a "final" state.
$pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
if (!empty($credit)) {
self::creditPayment($payment, $mollie_payment);
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return 200;
}
/**
* Get Mollie customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
+ * @param bool $create Create the customer if does not exist yet
*
- * @return string Mollie customer identifier
+ * @return ?string Mollie customer identifier
*/
- protected static function mollieCustomerId(Wallet $wallet): string
+ protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('mollie_id');
// Register the user in Mollie
- if (empty($customer_id)) {
+ if (empty($customer_id) && $create) {
$customer = mollie()->customers()->create([
'name' => $wallet->owner->name(),
'email' => $wallet->id . '@private.' . \config('app.domain'),
]);
$customer_id = $customer->id;
$wallet->setSetting('mollie_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Mollie auto-payment mandate
*/
protected static function mollieMandate(Wallet $wallet)
{
$customer_id = $wallet->getSetting('mollie_id');
$mandate_id = $wallet->getSetting('mollie_mandate_id');
// Get the manadate reference we already have
if ($customer_id && $mandate_id) {
$mandate = mollie()->mandates()->getForId($customer_id, $mandate_id);
if ($mandate) {// && ($mandate->isValid() || $mandate->isPending())) {
return $mandate;
}
}
// Get all mandates from Mollie and find the active one
/*
foreach ($customer->mandates() as $mandate) {
if ($mandate->isValid() || $mandate->isPending()) {
$wallet->setSetting('mollie_mandate_id', $mandate->id);
return $mandate;
}
}
*/
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment($payment, $mollie_payment)
{
// Extract the payment method for transaction description
$method = self::paymentMethod($mollie_payment, 'Mollie');
// TODO: Localization?
$description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
$description .= " transaction {$payment->id} using {$method}";
$payment->wallet->credit($payment->amount, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* Extract payment method description from Mollie payment/mandate details
*/
protected static function paymentMethod($object, $default = ''): string
{
$details = $object->details;
// Mollie supports 3 methods here
switch ($object->method) {
case 'creditcard':
// If the customer started, but never finished the 'first' payment
// card details will be empty, and mandate will be 'pending'.
if (empty($details->cardNumber)) {
return 'Credit Card';
}
return sprintf(
'%s (**** **** **** %s)',
$details->cardLabel ?: 'Card', // @phpstan-ignore-line
$details->cardNumber
);
case 'directdebit':
return sprintf('Direct Debit (%s)', $details->customerAccount);
case 'paypal':
return sprintf('PayPal (%s)', $details->consumerAccount);
}
return $default;
}
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
index be0c614e..cbde016b 100644
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -1,460 +1,465 @@
tag
*/
public function customerLink(Wallet $wallet): ?string
{
- $customer_id = self::stripeCustomerId($wallet);
+ $customer_id = self::stripeCustomerId($wallet, false);
+
+ if (!$customer_id) {
+ return null;
+ }
$location = 'https://dashboard.stripe.com';
$key = \config('services.stripe.key');
if (strpos($key, 'sk_test_') === 0) {
$location .= '/test';
}
return sprintf(
'%s',
$location,
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - description: Operation desc.
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Stripe, if not yet done
- $customer_id = self::stripeCustomerId($wallet);
+ $customer_id = self::stripeCustomerId($wallet, true);
$request = [
'customer' => $customer_id,
'cancel_url' => \url('/wallet'), // required
'success_url' => \url('/wallet'), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'mode' => 'setup',
];
$session = StripeAPI\Checkout\Session::create($request);
$payment = [
'id' => $session->setup_intent,
'type' => self::TYPE_MANDATE,
];
$this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
];
}
/**
* Revoke the auto-payment mandate.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::stripeMandate($wallet);
if ($mandate) {
// Remove the reference
$wallet->setSetting('stripe_mandate_id', null);
// Detach the payment method on Stripe
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
$pm->detach();
}
return true;
}
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::stripeMandate($wallet);
if (empty($mandate)) {
return null;
}
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
$result = [
'id' => $mandate->id,
'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled',
'isValid' => $mandate->status == 'succeeded',
'method' => self::paymentMethod($pm, 'Unknown method')
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'stripe';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation desc.
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == self::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Stripe, if not yet done
- $customer_id = self::stripeCustomerId($wallet);
+ $customer_id = self::stripeCustomerId($wallet, true);
$request = [
'customer' => $customer_id,
'cancel_url' => \url('/wallet'), // required
'success_url' => \url('/wallet'), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'line_items' => [
[
'name' => $payment['description'],
'amount' => $payment['amount'],
'currency' => \strtolower($payment['currency']),
'quantity' => 1,
]
]
];
$session = StripeAPI\Checkout\Session::create($request);
// Store the payment reference in database
$payment['id'] = $session->payment_intent;
$this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
];
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::stripeMandate($wallet);
if (empty($mandate)) {
return null;
}
$request = [
'amount' => $payment['amount'],
'currency' => \strtolower($payment['currency']),
'description' => $payment['description'],
'receipt_email' => $wallet->owner->email,
'customer' => $mandate->customer,
'payment_method' => $mandate->payment_method,
'off_session' => true,
'confirm' => true,
];
$intent = StripeAPI\PaymentIntent::create($request);
// Store the payment reference in database
$payment['id'] = $intent->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
// We cannot just use php://input as it's already "emptied" by the framework
// $payload = file_get_contents('php://input');
$request = Request::instance();
$payload = $request->getContent();
$sig_header = $request->header('Stripe-Signature');
// Parse and validate the input
try {
$event = StripeAPI\Webhook::constructEvent(
$payload,
$sig_header,
\config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
// Invalid payload
return 400;
}
switch ($event->type) {
case StripeAPI\Event::PAYMENT_INTENT_CANCELED:
case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED:
case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED:
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
if (empty($payment) || $payment->type == self::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\PaymentIntent::STATUS_CANCELED:
$status = self::STATUS_CANCELED;
break;
case StripeAPI\PaymentIntent::STATUS_SUCCEEDED:
$status = self::STATUS_PAID;
break;
default:
$status = self::STATUS_FAILED;
}
DB::beginTransaction();
if ($status == self::STATUS_PAID) {
// Update the balance, if it wasn't already
if ($payment->status != self::STATUS_PAID) {
$this->creditPayment($payment, $intent);
}
} else {
if (!empty($intent->last_payment_error)) {
// See https://stripe.com/docs/error-codes for more info
\Log::info(sprintf(
'Stripe payment failed (%s): %s',
$payment->id,
json_encode($intent->last_payment_error)
));
}
}
if ($payment->status != self::STATUS_PAID) {
$payment->status = $status;
$payment->save();
if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) {
// Disable the mandate
if ($status == self::STATUS_FAILED) {
$payment->wallet->setSetting('mandate_disabled', 1);
}
// Notify the user
\App\Jobs\PaymentEmail::dispatch($payment);
}
}
DB::commit();
break;
case StripeAPI\Event::SETUP_INTENT_SUCCEEDED:
case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED:
case StripeAPI\Event::SETUP_INTENT_CANCELED:
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
if (empty($payment) || $payment->type != self::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\SetupIntent::STATUS_CANCELED:
$status = self::STATUS_CANCELED;
break;
case StripeAPI\SetupIntent::STATUS_SUCCEEDED:
$status = self::STATUS_PAID;
break;
default:
$status = self::STATUS_FAILED;
}
if ($status == self::STATUS_PAID) {
$payment->wallet->setSetting('stripe_mandate_id', $intent->id);
}
$payment->status = $status;
$payment->save();
break;
default:
\Log::debug("Unhandled Stripe event: " . var_export($payload, true));
break;
}
return 200;
}
/**
* Get Stripe customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
+ * @param bool $create Create the customer if does not exist yet
*
- * @return string Stripe customer identifier
+ * @return string|null Stripe customer identifier
*/
- protected static function stripeCustomerId(Wallet $wallet): string
+ protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('stripe_id');
// Register the user in Stripe
- if (empty($customer_id)) {
+ if (empty($customer_id) && $create) {
$customer = StripeAPI\Customer::create([
'name' => $wallet->owner->name(),
// Stripe will display the email on Checkout page, editable,
// and use it to send the receipt (?), use the user email here
// 'email' => $wallet->id . '@private.' . \config('app.domain'),
'email' => $wallet->owner->email,
]);
$customer_id = $customer->id;
$wallet->setSetting('stripe_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Stripe auto-payment mandate (Setup Intent)
*/
protected static function stripeMandate(Wallet $wallet)
{
// Note: Stripe also has 'Mandate' objects, but we do not use these
if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) {
$mandate = StripeAPI\SetupIntent::retrieve($mandate_id);
// @phpstan-ignore-next-line
if ($mandate && $mandate->status != 'canceled') {
return $mandate;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment(Payment $payment, $intent)
{
$method = 'Stripe';
// Extract the payment method for transaction description
if (
!empty($intent->charges)
&& ($charge = $intent->charges->data[0])
&& ($pm = $charge->payment_method_details)
) {
$method = self::paymentMethod($pm);
}
// TODO: Localization?
$description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
$description .= " transaction {$payment->id} using {$method}";
$payment->wallet->credit($payment->amount, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* Extract payment method description from Stripe payment details
*/
protected static function paymentMethod($details, $default = ''): string
{
switch ($details->type) {
case 'card':
// TODO: card number
return \sprintf(
'%s (**** **** **** %s)',
\ucfirst($details->card->brand) ?: 'Card',
$details->card->last4
);
}
return $default;
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 8cff740d..23b2bdad 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,348 +1,346 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap')
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
import store from './store'
const loader = '
').text(msg_text)
if (input.is('.list-input')) {
// List input widget
input.children(':not(:first-child)').each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
}
else {
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.listinput-widget)').first().focus()
})
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toast.error(error_msg || "Server Error")
// Pass the error as-is
return Promise.reject(error)
}
)
diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue
index cda47dc6..7bc5d640 100644
--- a/src/resources/vue/App.vue
+++ b/src/resources/vue/App.vue
@@ -1,50 +1,50 @@
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
index 6831e4fd..4a464ab6 100644
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,370 +1,376 @@
User account
New user account
diff --git a/src/tests/Browser/Admin/DashboardTest.php b/src/tests/Browser/Admin/DashboardTest.php
index 7e1f480f..c0d62dd6 100644
--- a/src/tests/Browser/Admin/DashboardTest.php
+++ b/src/tests/Browser/Admin/DashboardTest.php
@@ -1,77 +1,79 @@
getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
parent::tearDown();
}
/**
* Test user search
*/
public function testSearch(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
->on(new Dashboard())
->assertFocused('@search input')
->assertMissing('@search table');
// Test search with no results
$browser->type('@search input', 'unknown')
->click('@search form button')
->assertToast(Toast::TYPE_INFO, '0 user accounts have been found.')
->assertMissing('@search table');
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', 'john.doe.external@gmail.com');
// Test search with multiple results
$browser->type('@search input', 'john.doe.external@gmail.com')
->click('@search form button')
->assertToast(Toast::TYPE_INFO, '2 user accounts have been found.')
->whenAvailable('@search table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2);
// TODO: Assert table content
});
// Test search with single record result -> redirect to user page
$browser->type('@search input', 'kolab.org')
->click('@search form button')
->assertMissing('@search table')
->waitForLocation('/user/' . $john->id)
- ->waitFor('#user-info')
- ->assertSeeIn('#user-info .card-title', $john->email);
+ ->waitUntilMissing('.app-loader')
+ ->whenAvailable('#user-info', function (Browser $browser) use ($john) {
+ $browser->assertSeeIn('.card-title', $john->email);
+ });
});
}
}
diff --git a/src/tests/Browser/Admin/UserFinancesTest.php b/src/tests/Browser/Admin/UserFinancesTest.php
index 3a64fdaa..aa63f5a9 100644
--- a/src/tests/Browser/Admin/UserFinancesTest.php
+++ b/src/tests/Browser/Admin/UserFinancesTest.php
@@ -1,314 +1,316 @@
getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->balance = 0;
$wallet->save();
+ $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]);
}
/**
* Test Finances tab (and transactions)
*/
public function testFinances(): void
{
// Assert Jack's Finances tab
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
- $jack->wallets()->first()->transactions()->delete();
+ $wallet = $jack->wallets()->first();
+ $wallet->transactions()->delete();
+ $wallet->setSetting('stripe_id', 'abc');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
->on(new Dashboard())
->visit($page)
->on($page)
->assertSeeIn('@nav #tab-finances', 'Finances')
->with('@user-finances', function (Browser $browser) {
$browser->waitUntilMissing('.app-loader')
->assertSeeIn('.card-title:first-child', 'Account balance')
->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF')
->with('form', function (Browser $browser) {
- $payment_provider = ucfirst(\config('services.payment_provider'));
$browser->assertElementsCount('.row', 2)
->assertSeeIn('.row:nth-child(1) label', 'Discount')
->assertSeeIn('.row:nth-child(1) #discount span', 'none')
- ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID')
- ->assertVisible('.row:nth-child(2) a');
+ ->assertSeeIn('.row:nth-child(2) label', 'Stripe ID')
+ ->assertSeeIn('.row:nth-child(2) a', 'abc');
})
->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
->with('table', function (Browser $browser) {
$browser->assertMissing('tbody')
->assertSeeIn('tfoot td', "There are no transactions for this account.");
})
->assertMissing('table + button');
});
});
// Assert John's Finances tab (with discount, and debit)
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->transactions()->delete();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
// Create test transactions
$transaction = Transaction::create([
'user_email' => 'jeroen@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => 100,
'description' => 'Payment',
]);
$transaction->created_at = Carbon::now()->subMonth();
$transaction->save();
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page)
->with('@user-finances', function (Browser $browser) {
$browser->waitUntilMissing('.app-loader')
->assertSeeIn('.card-title:first-child', 'Account balance')
->assertSeeIn('.card-title:first-child .text-danger', '-20,10 CHF')
->with('form', function (Browser $browser) {
- $browser->assertElementsCount('.row', 2)
+ $browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:nth-child(1) label', 'Discount')
->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher');
})
->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
->with('table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertMissing('tfoot')
->assertSeeIn('tbody tr:last-child td.email', 'jeroen@jeroen.jeroen');
});
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$page = new UserPage($ned->id);
$browser->click('@nav #tab-users')
->click('@user-users tbody tr:nth-child(3) td:first-child a')
->on($page)
->with('@user-finances', function (Browser $browser) {
$browser->waitUntilMissing('.app-loader')
->assertSeeIn('.card-title:first-child', 'Account balance')
->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF')
->with('form', function (Browser $browser) {
- $browser->assertElementsCount('.row', 2)
+ $browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:nth-child(1) label', 'Discount')
->assertSeeIn('.row:nth-child(1) #discount span', 'none');
})
->assertSeeIn('h2:nth-of-type(2)', 'Transactions')
->with('table', function (Browser $browser) {
$browser->assertMissing('tbody')
->assertSeeIn('tfoot td', "There are no transactions for this account.");
})
->assertMissing('table + button');
});
});
}
/**
* Test editing wallet discount
*
* @depends testFinances
*/
public function testWalletDiscount(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->pause(100)
->waitUntilMissing('@user-finances .app-loader')
->click('@user-finances #discount button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#discount-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Account discount')
->assertFocused('@body select')
->assertSelected('@body select', '')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#discount-dialog')
->click('@user-finances #discount button')
// Change the discount
->with(new Dialog('#discount-dialog'), function (Browser $browser) {
$browser->click('@body select')
->click('@body select option:nth-child(2)')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.')
->assertSeeIn('#discount span', '10% - Test voucher')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
})
// Change back to 'none'
->click('@nav #tab-finances')
->click('@user-finances #discount button')
->with(new Dialog('#discount-dialog'), function (Browser $browser) {
$browser->click('@body select')
->click('@body select option:nth-child(1)')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.')
->assertSeeIn('#discount span', 'none')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF/month')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF/month')
->assertMissing('table + .hint');
});
});
}
/**
* Test awarding/penalizing a wallet
*
* @depends testFinances
*/
public function testBonusPenalty(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-finances #button-award')
->click('@user-finances #button-award')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Add a bonus to the wallet')
->assertFocused('@body input#oneoff_amount')
->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount')
->assertvalue('@body input#oneoff_amount', '')
->assertSeeIn('@body label[for="oneoff_description"]', 'Description')
->assertvalue('@body input#oneoff_description', '')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#oneoff-dialog');
// Test bonus
$browser->click('@user-finances #button-award')
->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
// Test input validation for a bonus
$browser->type('@body #oneoff_amount', 'aaa')
->type('@body #oneoff_description', '')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #oneoff_amount.is-invalid')
->assertVisible('@body #oneoff_description.is-invalid')
->assertSeeIn(
'@body #oneoff_amount + span + .invalid-feedback',
'The amount must be a number.'
)
->assertSeeIn(
'@body #oneoff_description + .invalid-feedback',
'The description field is required.'
);
// Test adding a bonus
$browser->type('@body #oneoff_amount', '12.34')
->type('@body #oneoff_description', 'Test bonus')
->click('@button-action')
->assertToast(Toast::TYPE_SUCCESS, 'The bonus has been added to the wallet successfully.');
})
->assertMissing('#oneoff-dialog')
->assertSeeIn('@user-finances .card-title span.text-success', '12,34 CHF')
->waitUntilMissing('.app-loader')
->with('table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 3)
->assertMissing('tfoot')
->assertSeeIn('tbody tr:first-child td.description', 'Bonus: Test bonus')
->assertSeeIn('tbody tr:first-child td.email', 'jeroen@jeroen.jeroen')
->assertSeeIn('tbody tr:first-child td.price', '12,34 CHF');
});
$this->assertSame(1234, $john->wallets()->first()->balance);
// Test penalty
$browser->click('@user-finances #button-penalty')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Add a penalty to the wallet')
->assertFocused('@body input#oneoff_amount')
->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount')
->assertvalue('@body input#oneoff_amount', '')
->assertSeeIn('@body label[for="oneoff_description"]', 'Description')
->assertvalue('@body input#oneoff_description', '')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#oneoff-dialog')
->click('@user-finances #button-penalty')
->with(new Dialog('#oneoff-dialog'), function (Browser $browser) {
// Test input validation for a penalty
$browser->type('@body #oneoff_amount', '')
->type('@body #oneoff_description', '')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #oneoff_amount.is-invalid')
->assertVisible('@body #oneoff_description.is-invalid')
->assertSeeIn(
'@body #oneoff_amount + span + .invalid-feedback',
'The amount field is required.'
)
->assertSeeIn(
'@body #oneoff_description + .invalid-feedback',
'The description field is required.'
);
// Test adding a penalty
$browser->type('@body #oneoff_amount', '12.35')
->type('@body #oneoff_description', 'Test penalty')
->click('@button-action')
->assertToast(Toast::TYPE_SUCCESS, 'The penalty has been added to the wallet successfully.');
})
->assertMissing('#oneoff-dialog')
->assertSeeIn('@user-finances .card-title span.text-danger', '-0,01 CHF');
$this->assertSame(-1, $john->wallets()->first()->balance);
});
}
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
index eef99650..dd56b46e 100644
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -1,399 +1,401 @@
getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
+ $wallet->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
+ $wallet->save();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States of America');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 5);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF')
->assertMissing('table tfoot');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States of America');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 5);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (3)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 3)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$page = new UserPage($ned->id);
$browser->click('@user-users tbody tr:nth-child(3) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 5);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 5)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
}
diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php
index a13078bc..bbc89167 100644
--- a/src/tests/Browser/DomainTest.php
+++ b/src/tests/Browser/DomainTest.php
@@ -1,134 +1,135 @@
browse(function ($browser) {
$browser->visit('/domain/123')->on(new Home());
});
}
/**
* Test domain info page (non-existing domain id)
*/
public function testDomainInfo404(): void
{
$this->browse(function ($browser) {
// FIXME: I couldn't make loginAs() method working
// Note: Here we're also testing that unauthenticated request
// is passed to logon form and then "redirected" to the requested page
$browser->visit('/domain/123')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123')
->assertErrorPage(404);
});
}
/**
* Test domain info page (existing domain)
*
* @depends testDomainInfo404
*/
public function testDomainInfo(): void
{
$this->browse(function ($browser) {
// Unconfirmed domain
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
->whenAvailable('@verify', function ($browser) use ($domain) {
$browser->assertSeeIn('pre', $domain->namespace)
->assertSeeIn('pre', $domain->hash())
->click('button')
->assertToast(Toast::TYPE_ERROR, 'Domain ownership verification failed.');
// Make sure the domain is confirmed now
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
$browser->click('button')
->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.');
})
->whenAvailable('@config', function ($browser) use ($domain) {
$browser->assertSeeIn('pre', $domain->namespace);
})
->assertMissing('@verify');
// Check that confirmed domain page contains only the config box
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
->assertMissing('@verify')
->assertPresent('@config');
});
}
/**
* Test domains list page (unauthenticated)
*/
public function testDomainListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function ($browser) {
$browser->visit('/logout')
->visit('/domains')
->on(new Home());
});
}
/**
* Test domains list page
*
* @depends testDomainListUnauth
*/
public function testDomainList(): void
{
$this->browse(function ($browser) {
// Login the user
$browser->visit('/login')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
// On dashboard click the "Domains" link
->on(new Dashboard())
->assertSeeIn('@links a.link-domains', 'Domains')
->click('@links a.link-domains')
// On Domains List page click the domain entry
->on(new DomainList())
+ ->waitFor('@table tbody tr')
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Active')
->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org')
->click('@table tbody tr:first-child td:first-child a')
// On Domain Info page verify that's the clicked domain
->on(new DomainInfo())
->whenAvailable('@config', function ($browser) {
$browser->assertSeeIn('pre', 'kolab.org');
});
});
// TODO: Test domains list acting as Ned (John's "delegatee")
}
}
diff --git a/src/tests/Browser/Pages/PaymentStripe.php b/src/tests/Browser/Pages/PaymentStripe.php
index 761c6333..f00067c0 100644
--- a/src/tests/Browser/Pages/PaymentStripe.php
+++ b/src/tests/Browser/Pages/PaymentStripe.php
@@ -1,67 +1,67 @@
waitFor('.App-Payment');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@form' => '.App-Payment > form',
- '@title' => '.App-Overview .ProductSummary-Info .Text',
- '@amount' => '#ProductSummary-TotalAmount',
+ '@title' => '.App-Overview .ProductSummary',
+ '@amount' => '#ProductSummary-totalAmount',
'@description' => '#ProductSummary-Description',
'@email-input' => '.App-Payment #email',
'@cardnumber-input' => '.App-Payment #cardNumber',
'@cardexpiry-input' => '.App-Payment #cardExpiry',
'@cardcvc-input' => '.App-Payment #cardCvc',
'@name-input' => '.App-Payment #billingName',
'@submit-button' => '.App-Payment form button.SubmitButton',
];
}
/**
* Submit payment form.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function submitValidCreditCard($browser)
{
$browser->type('@name-input', 'Test')
->typeSlowly('@cardnumber-input', '4242424242424242', 50)
->type('@cardexpiry-input', '12/' . (intval(date('y')) + 1))
->type('@cardcvc-input', '123')
->press('@submit-button');
}
}
diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php
index 2a283c03..53046e82 100644
--- a/src/tests/Browser/Pages/UserInfo.php
+++ b/src/tests/Browser/Pages/UserInfo.php
@@ -1,46 +1,47 @@
waitFor('@form');
+ $browser->waitFor('@form')
+ ->waitUntilMissing('.app-loader');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@form' => '#user-info form',
'@packages' => '#user-packages',
'@skus' => '#user-skus',
'@status' => '#status-box',
];
}
}
diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php
index f3545a18..64762b8d 100644
--- a/src/tests/Browser/PasswordResetTest.php
+++ b/src/tests/Browser/PasswordResetTest.php
@@ -1,274 +1,276 @@
deleteTestUser('passwordresettestdusk@' . \config('app.domain'));
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('passwordresettestdusk@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test the link from logon to password-reset page
*/
public function testPasswordResetLinkOnLogon(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Home());
$browser->assertSeeLink('Forgot password?');
$browser->clickLink('Forgot password?');
$browser->on(new PasswordReset());
$browser->assertVisible('@step1');
});
}
/**
* Test 1st step of password-reset
*/
public function testPasswordResetStep1(): void
{
$user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain'));
$user->setSetting('external_email', 'external@domain.tld');
$this->browse(function (Browser $browser) {
$browser->visit(new PasswordReset());
$browser->assertVisible('@step1');
// Here we expect email input and submit button
$browser->with('@step1', function ($step) {
$step->assertVisible('#reset_email');
$step->assertFocused('#reset_email');
$step->assertVisible('[type=submit]');
});
// Submit empty form
$browser->with('@step1', function ($step) {
$step->click('[type=submit]');
$step->assertFocused('#reset_email');
});
// Submit invalid email
// We expect email input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step1', function ($step) use ($browser) {
$step->type('#reset_email', '@test');
$step->click('[type=submit]');
$step->waitFor('#reset_email.is-invalid');
$step->waitFor('#reset_email + .invalid-feedback');
$browser->waitFor('.toast-error');
$browser->click('.toast-error'); // remove the toast
});
// Submit valid data
$browser->with('@step1', function ($step) {
$step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain'));
$step->click('[type=submit]');
$step->assertMissing('#reset_email.is-invalid');
$step->assertMissing('#reset_email + .invalid-feedback');
});
$browser->waitUntilMissing('@step2 #reset_code[value=""]');
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
});
}
/**
* Test 2nd Step of the password reset process
*
* @depends testPasswordResetStep1
*/
public function testPasswordResetStep2(): void
{
$user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain'));
$user->setSetting('external_email', 'external@domain.tld');
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step2');
// Here we expect one text input, Back and Continue buttons
$browser->with('@step2', function ($step) {
$step->assertVisible('#reset_short_code');
$step->assertFocused('#reset_short_code');
$step->assertVisible('[type=button]');
$step->assertVisible('[type=submit]');
});
// Test Back button functionality
$browser->click('@step2 [type=button]');
$browser->waitFor('@step1');
$browser->assertFocused('@step1 #reset_email');
$browser->assertMissing('@step2');
// Submit valid Step 1 data (again)
$browser->with('@step1', function ($step) {
$step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain'));
$step->click('[type=submit]');
});
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
// Submit invalid code
// We expect code input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step2', function ($step) use ($browser) {
$step->type('#reset_short_code', 'XXXXX');
$step->click('[type=submit]');
$browser->waitFor('.toast-error');
- $step->assertVisible('#reset_short_code.is-invalid');
- $step->assertVisible('#reset_short_code + .invalid-feedback');
- $step->assertFocused('#reset_short_code');
+ $step->waitFor('#reset_short_code.is-invalid')
+ ->assertVisible('#reset_short_code.is-invalid')
+ ->assertVisible('#reset_short_code + .invalid-feedback')
+ ->assertFocused('#reset_short_code');
$browser->click('.toast-error'); // remove the toast
});
// Submit valid code
// We expect error state on code input to be removed, and Step 3 form visible
$browser->with('@step2', function ($step) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $step->value('#reset_code');
$this->assertNotEmpty($code);
$code = VerificationCode::find($code);
$step->type('#reset_short_code', $code->short_code);
$step->click('[type=submit]');
$step->assertMissing('#reset_short_code.is-invalid');
$step->assertMissing('#reset_short_code + .invalid-feedback');
});
$browser->waitFor('@step3');
$browser->assertMissing('@step2');
});
}
/**
* Test 3rd Step of the password reset process
*
* @depends testPasswordResetStep2
*/
public function testPasswordResetStep3(): void
{
$user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain'));
$user->setSetting('external_email', 'external@domain.tld');
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step3');
// Here we expect 2 text inputs, Back and Continue buttons
$browser->with('@step3', function ($step) {
$step->assertVisible('#reset_password');
$step->assertVisible('#reset_confirm');
$step->assertVisible('[type=button]');
$step->assertVisible('[type=submit]');
$step->assertFocused('#reset_password');
});
// Test Back button
$browser->click('@step3 [type=button]');
$browser->waitFor('@step2');
$browser->assertFocused('@step2 #reset_short_code');
$browser->assertMissing('@step3');
$browser->assertMissing('@step1');
// TODO: Test form reset when going back
// Because the verification code is removed in tearDown()
// we'll start from the beginning (Step 1)
$browser->click('@step2 [type=button]');
$browser->waitFor('@step1');
$browser->assertFocused('@step1 #reset_email');
$browser->assertMissing('@step3');
$browser->assertMissing('@step2');
// Submit valid data
$browser->with('@step1', function ($step) {
$step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain'));
$step->click('[type=submit]');
});
$browser->waitFor('@step2');
$browser->waitUntilMissing('@step2 #reset_code[value=""]');
// Submit valid code again
$browser->with('@step2', function ($step) {
$code = $step->value('#reset_code');
$this->assertNotEmpty($code);
$code = VerificationCode::find($code);
$step->type('#reset_short_code', $code->short_code);
$step->click('[type=submit]');
});
$browser->waitFor('@step3');
// Submit invalid data
$browser->with('@step3', function ($step) use ($browser) {
$step->assertFocused('#reset_password');
$step->type('#reset_password', '12345678');
$step->type('#reset_confirm', '123456789');
$step->click('[type=submit]');
$browser->waitFor('.toast-error');
- $step->assertVisible('#reset_password.is-invalid');
- $step->assertVisible('#reset_password + .invalid-feedback');
- $step->assertFocused('#reset_password');
+ $step->waitFor('#reset_password.is-invalid')
+ ->assertVisible('#reset_password.is-invalid')
+ ->assertVisible('#reset_password + .invalid-feedback')
+ ->assertFocused('#reset_password');
$browser->click('.toast-error'); // remove the toast
});
// Submit valid data
$browser->with('@step3', function ($step) {
$step->type('#reset_confirm', '12345678');
$step->click('[type=submit]');
});
$browser->waitUntilMissing('@step3');
// At this point we should be auto-logged-in to dashboard
$browser->on(new Dashboard());
// FIXME: Is it enough to be sure user is logged in?
});
}
}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
index d7f2aebe..064bf4a2 100644
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -1,536 +1,537 @@
deleteTestUser('signuptestdusk@' . \config('app.domain'));
$this->deleteTestUser('admin@user-domain-signup.com');
$this->deleteTestDomain('user-domain-signup.com');
}
public function tearDown(): void
{
$this->deleteTestUser('signuptestdusk@' . \config('app.domain'));
$this->deleteTestUser('admin@user-domain-signup.com');
$this->deleteTestDomain('user-domain-signup.com');
parent::tearDown();
}
/**
* Test signup code verification with a link
*/
public function testSignupCodeByLink(): void
{
// Test invalid code (invalid format)
$this->browse(function (Browser $browser) {
// Register Signup page element selectors we'll be using
$browser->onWithoutAssert(new Signup());
// TODO: Test what happens if user is logged in
$browser->visit('/signup/invalid-code');
// TODO: According to https://github.com/vuejs/vue-router/issues/977
// it is not yet easily possible to display error page component (route)
// without changing the URL
// TODO: Instead of css selector we should probably define page/component
// and use it instead
$browser->waitFor('#error-page');
});
// Test invalid code (valid format)
$this->browse(function (Browser $browser) {
$browser->visit('/signup/XXXXX-code');
// FIXME: User will not be able to continue anyway, so we should
// either display 1st step or 404 error page
$browser->waitFor('@step1')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Test valid code
$this->browse(function (Browser $browser) {
$code = SignupCode::create([
'data' => [
'email' => 'User@example.org',
'first_name' => 'User',
'last_name' => 'Name',
'plan' => 'individual',
'voucher' => '',
]
]);
$browser->visit('/signup/' . $code->short_code . '-' . $code->code)
->waitFor('@step3')
->assertMissing('@step1')
->assertMissing('@step2');
// FIXME: Find a nice way to read javascript data without using hidden inputs
$this->assertSame($code->code, $browser->value('@step2 #signup_code'));
// TODO: Test if the signup process can be completed
});
}
/**
* Test signup "welcome" page
*/
public function testSignupStep0(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Signup());
$browser->assertVisible('@step0')
->assertMissing('@step1')
->assertMissing('@step2')
->assertMissing('@step3');
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'], 'signup');
});
$browser->waitFor('@step0 .plan-selector > .plan-box');
// Assert first plan box and press the button
$browser->with('@step0 .plan-selector > .plan-individual', function ($step) {
$step->assertVisible('button')
->assertSeeIn('button', 'Individual Account')
->assertVisible('.plan-description')
->click('button');
});
$browser->waitForLocation('/signup/individual')
->assertVisible('@step1')
->assertMissing('@step0')
->assertMissing('@step2')
->assertMissing('@step3')
->assertFocused('@step1 #signup_first_name');
// Click Back button
$browser->click('@step1 [type=button]')
->waitForLocation('/signup')
->assertVisible('@step0')
->assertMissing('@step1')
->assertMissing('@step2')
->assertMissing('@step3');
// Choose the group account plan
$browser->click('@step0 .plan-selector > .plan-group button')
->waitForLocation('/signup/group')
->assertVisible('@step1')
->assertMissing('@step0')
->assertMissing('@step2')
->assertMissing('@step3')
->assertFocused('@step1 #signup_first_name');
// TODO: Test if 'plan' variable is set properly in vue component
});
}
/**
* Test 1st step of the signup process
*/
public function testSignupStep1(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/signup/individual')
->onWithoutAssert(new Signup());
// Here we expect two text inputs and Back and Continue buttons
$browser->with('@step1', function ($step) {
$step->assertVisible('#signup_last_name')
->assertVisible('#signup_first_name')
->assertFocused('#signup_first_name')
->assertVisible('#signup_email')
->assertVisible('[type=button]')
->assertVisible('[type=submit]');
});
// Submit empty form
// Email is required, so after pressing Submit
// we expect focus to be moved to the email input
$browser->with('@step1', function ($step) {
$step->click('[type=submit]');
$step->assertFocused('#signup_email');
});
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'], 'signup');
});
// Submit invalid email, and first_name
// We expect both inputs to have is-invalid class added, with .invalid-feedback element
$browser->with('@step1', function ($step) {
$step->type('#signup_first_name', str_repeat('a', 250))
->type('#signup_email', '@test')
->click('[type=submit]')
->waitFor('#signup_email.is-invalid')
->assertVisible('#signup_first_name.is-invalid')
->assertVisible('#signup_email + .invalid-feedback')
->assertVisible('#signup_last_name + .invalid-feedback')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit valid data
// We expect error state on email input to be removed, and Step 2 form visible
$browser->with('@step1', function ($step) {
$step->type('#signup_first_name', 'Test')
->type('#signup_last_name', 'User')
->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
->click('[type=submit]')
->assertMissing('#signup_email.is-invalid')
->assertMissing('#signup_email + .invalid-feedback');
});
$browser->waitUntilMissing('@step2 #signup_code[value=""]');
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
});
}
/**
* Test 2nd Step of the signup process
*
* @depends testSignupStep1
*/
public function testSignupStep2(): void
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step2')
->assertMissing('@step0')
->assertMissing('@step1')
->assertMissing('@step3');
// Here we expect one text input, Back and Continue buttons
$browser->with('@step2', function ($step) {
$step->assertVisible('#signup_short_code')
->assertFocused('#signup_short_code')
->assertVisible('[type=button]')
->assertVisible('[type=submit]');
});
// Test Back button functionality
$browser->click('@step2 [type=button]')
->waitFor('@step1')
->assertFocused('@step1 #signup_first_name')
->assertMissing('@step2');
// Submit valid Step 1 data (again)
$browser->with('@step1', function ($step) {
$step->type('#signup_first_name', 'User')
->type('#signup_last_name', 'User')
->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
->click('[type=submit]');
});
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
// Submit invalid code
// We expect code input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step2', function ($step) {
$step->type('#signup_short_code', 'XXXXX');
$step->click('[type=submit]');
$step->waitFor('#signup_short_code.is-invalid')
->assertVisible('#signup_short_code + .invalid-feedback')
->assertFocused('#signup_short_code')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit valid code
// We expect error state on code input to be removed, and Step 3 form visible
$browser->with('@step2', function ($step) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $step->value('#signup_code');
$this->assertNotEmpty($code);
$code = SignupCode::find($code);
$step->type('#signup_short_code', $code->short_code);
$step->click('[type=submit]');
$step->assertMissing('#signup_short_code.is-invalid');
$step->assertMissing('#signup_short_code + .invalid-feedback');
});
$browser->waitFor('@step3');
$browser->assertMissing('@step2');
});
}
/**
* Test 3rd Step of the signup process
*
* @depends testSignupStep2
*/
public function testSignupStep3(): void
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step3');
// Here we expect 3 text inputs, Back and Continue buttons
$browser->with('@step3', function ($step) {
$step->assertVisible('#signup_login');
$step->assertVisible('#signup_password');
$step->assertVisible('#signup_confirm');
$step->assertVisible('select#signup_domain');
$step->assertVisible('[type=button]');
$step->assertVisible('[type=submit]');
$step->assertFocused('#signup_login');
$step->assertValue('select#signup_domain', \config('app.domain'));
$step->assertValue('#signup_login', '');
$step->assertValue('#signup_password', '');
$step->assertValue('#signup_confirm', '');
// TODO: Test domain selector
});
// Test Back button
$browser->click('@step3 [type=button]');
$browser->waitFor('@step2');
$browser->assertFocused('@step2 #signup_short_code');
$browser->assertMissing('@step3');
// TODO: Test form reset when going back
// Submit valid code again
$browser->with('@step2', function ($step) {
$code = $step->value('#signup_code');
$this->assertNotEmpty($code);
$code = SignupCode::find($code);
$step->type('#signup_short_code', $code->short_code);
$step->click('[type=submit]');
});
$browser->waitFor('@step3');
// Submit invalid data
$browser->with('@step3', function ($step) {
$step->assertFocused('#signup_login')
->type('#signup_login', '*')
->type('#signup_password', '12345678')
->type('#signup_confirm', '123456789')
->click('[type=submit]')
->waitFor('#signup_login.is-invalid')
->assertVisible('#signup_domain + .invalid-feedback')
->assertVisible('#signup_password.is-invalid')
->assertVisible('#signup_password + .invalid-feedback')
->assertFocused('#signup_login')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit invalid data (valid login, invalid password)
$browser->with('@step3', function ($step) {
$step->type('#signup_login', 'SignupTestDusk')
->click('[type=submit]')
->waitFor('#signup_password.is-invalid')
->assertVisible('#signup_password + .invalid-feedback')
->assertMissing('#signup_login.is-invalid')
->assertMissing('#signup_domain + .invalid-feedback')
->assertFocused('#signup_password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit valid data
$browser->with('@step3', function ($step) {
$step->type('#signup_confirm', '12345678');
$step->click('[type=submit]');
});
// At this point we should be auto-logged-in to dashboard
$browser->waitUntilMissing('@step3')
->waitUntilMissing('.app-loader')
->on(new Dashboard())
->assertUser('signuptestdusk@' . \config('app.domain'));
// Logout the user
$browser->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
}
/**
* Test signup for a group account
*/
public function testSignupGroup(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Signup());
// Choose the group account plan
$browser->waitFor('@step0 .plan-group button')
->click('@step0 .plan-group button');
// Submit valid data
// We expect error state on email input to be removed, and Step 2 form visible
$browser->whenAvailable('@step1', function ($step) {
$step->type('#signup_first_name', 'Test')
->type('#signup_last_name', 'User')
->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
->click('[type=submit]');
});
// Submit valid code
$browser->whenAvailable('@step2', function ($step) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $step->value('#signup_code');
$code = SignupCode::find($code);
$step->type('#signup_short_code', $code->short_code)
->click('[type=submit]');
});
// Here we expect 4 text inputs, Back and Continue buttons
$browser->whenAvailable('@step3', function ($step) {
$step->assertVisible('#signup_login')
->assertVisible('#signup_password')
->assertVisible('#signup_confirm')
->assertVisible('input#signup_domain')
->assertVisible('[type=button]')
->assertVisible('[type=submit]')
->assertFocused('#signup_login')
->assertValue('input#signup_domain', '')
->assertValue('#signup_login', '')
->assertValue('#signup_password', '')
->assertValue('#signup_confirm', '');
});
// Submit invalid login and password data
$browser->with('@step3', function ($step) {
$step->assertFocused('#signup_login')
->type('#signup_login', '*')
->type('#signup_domain', 'test.com')
->type('#signup_password', '12345678')
->type('#signup_confirm', '123456789')
->click('[type=submit]')
->waitFor('#signup_login.is-invalid')
->assertVisible('#signup_domain + .invalid-feedback')
->assertVisible('#signup_password.is-invalid')
->assertVisible('#signup_password + .invalid-feedback')
->assertFocused('#signup_login')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit invalid domain
$browser->with('@step3', function ($step) {
$step->type('#signup_login', 'admin')
->type('#signup_domain', 'aaa')
->type('#signup_password', '12345678')
->type('#signup_confirm', '12345678')
->click('[type=submit]')
->waitUntilMissing('#signup_login.is-invalid')
->waitFor('#signup_domain.is-invalid + .invalid-feedback')
->assertMissing('#signup_password.is-invalid')
->assertMissing('#signup_password + .invalid-feedback')
->assertFocused('#signup_domain')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit invalid domain
$browser->with('@step3', function ($step) {
$step->type('#signup_domain', 'user-domain-signup.com')
->click('[type=submit]');
});
// At this point we should be auto-logged-in to dashboard
$browser->waitUntilMissing('@step3')
->waitUntilMissing('.app-loader')
->on(new Dashboard())
->assertUser('admin@user-domain-signup.com');
$browser->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
}
/**
* Test signup with voucher
*/
public function testSignupVoucherLink(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/signup/voucher/TEST')
->onWithoutAssert(new Signup())
+ ->waitUntilMissing('.app-loader')
->waitFor('@step0')
->click('.plan-individual button')
->whenAvailable('@step1', function (Browser $browser) {
$browser->type('#signup_first_name', 'Test')
->type('#signup_last_name', 'User')
->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
->click('[type=submit]');
})
->whenAvailable('@step2', function (Browser $browser) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $browser->value('#signup_code');
$this->assertNotEmpty($code);
$code = SignupCode::find($code);
$browser->type('#signup_short_code', $code->short_code)
->click('[type=submit]');
})
->whenAvailable('@step3', function (Browser $browser) {
// Assert that the code is filled in the input
// Change it and test error handling
$browser->assertValue('#signup_voucher', 'TEST')
->type('#signup_voucher', 'TESTXX')
->type('#signup_login', 'signuptestdusk')
->type('#signup_password', '123456789')
->type('#signup_confirm', '123456789')
->click('[type=submit]')
->waitFor('#signup_voucher.is-invalid')
->assertVisible('#signup_voucher + .invalid-feedback')
->assertFocused('#signup_voucher')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Submit the correct code
->type('#signup_voucher', 'TEST')
->click('[type=submit]');
})
->waitUntilMissing('@step3')
->waitUntilMissing('.app-loader')
->on(new Dashboard())
->assertUser('signuptestdusk@' . \config('app.domain'))
// Logout the user
->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
$user = $this->getTestUser('signuptestdusk@' . \config('app.domain'));
$discount = Discount::where('code', 'TEST')->first();
$this->assertSame($discount->id, $user->wallets()->first()->discount_id);
}
}
diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php
index 3ae338cf..b55e4f32 100644
--- a/src/tests/Browser/StatusTest.php
+++ b/src/tests/Browser/StatusTest.php
@@ -1,268 +1,270 @@
first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$john = $this->getTestUser('john@kolab.org');
$john->created_at = Carbon::now();
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->with(new Status(), function ($browser) use ($john) {
$browser->assertSeeIn('@body', 'We are preparing your account')
->assertProgress(28, 'Creating a mailbox...', 'pending')
->assertMissing('#status-verify')
->assertMissing('#status-link')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
// Wait for auto-refresh, expect domain-confirmed step
$browser->pause(6000)
->assertSeeIn('@body', 'Your account is almost ready')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-verify')
->assertVisible('#status-link');
})
// check if the link to domain info page works
->click('#status-link')
->on(new DomainInfo())
->back()
->on(new Dashboard())
->with(new Status(), function ($browser) {
$browser->assertMissing('@refresh-button')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed');
});
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// This should take less than 10 seconds
$browser->waitUntilMissing('@status', 10);
});
// Test the Refresh button
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$john->created_at = Carbon::now()->subSeconds(3600);
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Dashboard())
->with(new Status(), function ($browser) use ($john, $domain) {
$browser->assertSeeIn('@body', 'We are preparing your account')
->assertProgress(28, 'Creating a mailbox...', 'failed')
->assertVisible('@refresh-button')
->assertVisible('@refresh-text');
if ($john->refresh()->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
}
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
$browser->click('@refresh-button')
->assertToast(Toast::TYPE_SUCCESS, 'Setup process finished successfully.');
})
->assertMissing('@status');
});
}
/**
* Test domain status on domains list and domain info page
*
* @depends testDashboard
*/
public function testDomainStatus(): void
{
$domain = Domain::where('namespace', 'kolab.org')->first();
$domain->created_at = Carbon::now();
$domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY;
$domain->save();
$this->browse(function ($browser) use ($domain) {
// Test auto-refresh
$browser->on(new Dashboard())
->click('@links a.link-domains')
->on(new DomainList())
+ ->waitFor('@table tbody tr')
// Assert domain status icon
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-danger')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Not Ready')
->click('@table tbody tr:first-child td:first-child a')
->on(new DomainInfo())
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'We are preparing the domain')
->assertProgress(50, 'Verifying a custom domain...', 'pending')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertMissing('#status-verify');
});
$domain->status |= Domain::STATUS_VERIFIED;
$domain->save();
// This should take less than 10 seconds
$browser->waitFor('@status.process-failed')
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'The domain is almost ready')
->assertProgress(75, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertVisible('#status-verify');
});
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Test Verify button
$browser->click('@status #status-verify')
->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.')
->waitUntilMissing('@status')
->assertMissing('@verify')
->assertVisible('@config');
});
}
/**
* Test user status on users list and user info page
*
* @depends testDashboard
*/
public function testUserStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->created_at = Carbon::now();
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Dashboard())
->click('@links a.link-users')
->on(new UserList())
+ ->waitFor('@table tbody tr')
// Assert user status icons
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Active')
->assertVisible('@table tbody tr:nth-child(3) td:first-child svg.fa-user.text-danger')
->assertText('@table tbody tr:nth-child(3) td:first-child svg title', 'Not Ready')
->click('@table tbody tr:nth-child(3) td:first-child a')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
// Assert state in the user edit form
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Not Ready');
})
->with(new Status(), function ($browser) use ($john) {
$browser->assertSeeIn('@body', 'We are preparing the user account')
->assertProgress(28, 'Creating a mailbox...', 'pending')
->assertMissing('#status-verify')
->assertMissing('#status-link')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
// Wait for auto-refresh, expect domain-confirmed step
$browser->pause(6000)
->assertSeeIn('@body', 'The user account is almost ready')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-verify')
->assertVisible('#status-link');
})
->assertSeeIn('#status', 'Active');
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// This should take less than 10 seconds
$browser->waitUntilMissing('@status', 10);
});
}
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index 361d6d2f..47773aad 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,560 +1,562 @@
'John',
'last_name' => 'Doe',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = User::where('email', 'john@kolab.org')->first();
$browser->visit('/user/' . $user->id)->on(new Home());
});
}
/**
* Test users list page (unauthenticated)
*/
public function testListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/users')->on(new Home());
});
}
/**
* Test users list page
*/
public function testList(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-users', 'User accounts')
->click('@links .link-users')
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 4)
+ $browser->waitFor('tbody tr')
+ ->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(1) button.button-delete')
->assertVisible('tbody tr:nth-child(2) button.button-delete')
->assertVisible('tbody tr:nth-child(3) button.button-delete')
->assertVisible('tbody tr:nth-child(4) button.button-delete');
});
});
}
/**
* Test user account editing page (not profile page)
*
* @depends testList
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
$browser->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Active')
->assertFocused('div.row:nth-child(2) input')
->assertSeeIn('div.row:nth-child(2) label', 'First name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
->assertSeeIn('div.row:nth-child(3) label', 'Last name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Email')
->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(5) input[type=text]')
->assertSeeIn('div.row:nth-child(6) label', 'Email aliases')
->assertVisible('div.row:nth-child(6) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Confirm password')
->assertValue('div.row:nth-child(8) input[type=password]', '')
->assertSeeIn('button[type=submit]', 'Submit');
// Clear some fields and submit
$browser->type('#first_name', '')
->type('#last_name', '')
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
// Test error handling (password)
$browser->with('@form', function (Browser $browser) {
$browser->type('#password', 'aaaaaa')
->type('#password_confirmation', '')
->click('button[type=submit]')
->waitFor('#password + .invalid-feedback')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// TODO: Test password change
// Test form error handling (aliases)
$browser->with('@form', function (Browser $browser) {
// TODO: For some reason, clearing the input value
// with ->type('#password', '') does not work, maybe some dusk/vue intricacy
// For now we just use the default password
$browser->type('#password', 'simple123')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
})
->with('@form', function (Browser $browser) {
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
});
// Test adding aliases
$browser->with('@form', function (Browser $browser) {
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
});
$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(9) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(9)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertDisabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/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',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
$browser->assertQuotaValue(2)->setQuotaValue(3);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/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',
'Mobile synchronization'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(5) td.selection input')
->assertEnabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
'Two factor authentication for webmail and administration panel'
)
->click('tbody tr:nth-child(4) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
});
$expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
// Test subscriptions interaction
$browser->with('@form', function (Browser $browser) {
$browser->with('@skus', function ($browser) {
// Uncheck 'groupware', expect activesync unchecked
$browser->click('#sku-input-groupware')
->assertNotChecked('#sku-input-groupware')
->assertNotChecked('#sku-input-activesync')
->assertEnabled('#sku-input-activesync')
->assertNotReadonly('#sku-input-activesync')
// Check 'activesync', expect an alert
->click('#sku-input-activesync')
->assertDialogOpened('Activesync requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-activesync')
// Check '2FA', expect 'activesync' unchecked and readonly
->click('#sku-input-2fa')
->assertChecked('#sku-input-2fa')
->assertNotChecked('#sku-input-activesync')
->assertReadonly('#sku-input-activesync')
// Uncheck '2FA'
->click('#sku-input-2fa')
->assertNotChecked('#sku-input-2fa')
->assertNotReadonly('#sku-input-activesync');
});
});
});
}
/**
* Test user adding page
*
* @depends testList
*/
public function testNewUser(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->assertSeeIn('button.create-user', 'Create user')
->click('button.create-user')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'New user account')
->with('@form', function (Browser $browser) {
// Assert form content
$browser->assertFocused('div.row:nth-child(1) input')
->assertSeeIn('div.row:nth-child(1) label', 'First name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Last name')
->assertValue('div.row:nth-child(2) input[type=text]', '')
->assertSeeIn('div.row:nth-child(3) label', 'Organization')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertSeeIn('div.row:nth-child(4) label', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', '')
->assertEnabled('div.row:nth-child(4) input[type=text]')
->assertSeeIn('div.row:nth-child(5) label', 'Email aliases')
->assertVisible('div.row:nth-child(5) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
->assertValue('div.row:nth-child(6) input[type=password]', '')
->assertSeeIn('div.row:nth-child(7) label', 'Confirm password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) 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')
->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month')
->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');
})
->assertMissing('@packages table + .hint')
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
->click('button[type=submit]')
->assertFocused('#password')
->type('#password', 'simple123')
->click('button[type=submit]')
->assertFocused('#password_confirmation')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.');
});
// Test form error handling (aliases)
$browser->with('@form', function (Browser $browser) {
$browser->type('#email', 'julia.roberts@kolab.org')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@form', function (Browser $browser) {
$browser->type('#first_name', 'Julia')
->type('#last_name', 'Roberts')
->type('#organization', 'Test Org')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
// check redirection to users list
->waitForLocation('/users')
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) 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']);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
});
}
/**
* Test user delete
*
* @depends testNewUser
*/
public function testDeleteUser(): void
{
// First create a new user
$john = $this->getTestUser('john@kolab.org');
$julia = $this->getTestUser('julia.roberts@kolab.org');
$package_kolab = \App\Package::where('title', 'kolab')->first();
$john->assignPackage($package_kolab, $julia);
// Test deleting non-controller user
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org')
->click('tbody tr:nth-child(4) button.button-delete');
})
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
->whenAvailable('@table', function (Browser $browser) {
$browser->click('tbody tr:nth-child(4) button.button-delete');
})
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$this->assertTrue(empty($julia));
// Test clicking Delete on the controller record redirects to /profile/delete
$browser
->with('@table', function (Browser $browser) {
$browser->click('tbody tr:nth-child(3) button.button-delete');
})
->waitForLocation('/profile/delete');
});
// Test that non-controller user cannot see/delete himself on the users list
// Note: Access to /profile/delete page is tested in UserProfileTest.php
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 0);
});
});
// Test that controller user (Ned) can see/delete all the users ???
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('ned@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertElementsCount('tbody button.button-delete', 4);
});
// TODO: Test the delete action in details
});
// TODO: Test what happens with the logged in user session after he's been deleted by another user
}
/**
* Test discounted sku/package prices in the UI
*/
public function testDiscountedPrices(): void
{
// Add 10% discount
$discount = Discount::where('code', 'TEST')->first();
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit(new UserList())
+ ->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.create-user')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
}
}
diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php
index 8759b2c4..c949dbc9 100644
--- a/src/tests/Feature/Controller/Admin/WalletsTest.php
+++ b/src/tests/Feature/Controller/Admin/WalletsTest.php
@@ -1,228 +1,228 @@
'stripe']);
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
$wallet->discount_id = null;
$wallet->save();
// Make sure there's no stripe/mollie identifiers
$wallet->setSetting('stripe_id', null);
$wallet->setSetting('stripe_mandate_id', null);
$wallet->setSetting('mollie_id', null);
$wallet->setSetting('mollie_mandate_id', null);
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(403);
// Admin user
$response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($wallet->id, $json['id']);
$this->assertSame('CHF', $json['currency']);
$this->assertSame($wallet->balance, $json['balance']);
$this->assertSame(0, $json['discount']);
$this->assertTrue(empty($json['description']));
$this->assertTrue(empty($json['discount_description']));
$this->assertTrue(!empty($json['provider']));
- $this->assertTrue(!empty($json['providerLink']));
+ $this->assertTrue(empty($json['providerLink']));
$this->assertTrue(!empty($json['mandate']));
}
/**
* Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off)
*/
public function testOneOff(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
$balance = $wallet->balance;
Transaction::where('object_id', $wallet->id)
->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY])
->delete();
// Non-admin user
$response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []);
$response->assertStatus(403);
// Admin user - invalid input
$post = ['amount' => 'aaaa'];
$response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('The amount must be a number.', $json['errors']['amount'][0]);
$this->assertSame('The description field is required.', $json['errors']['description'][0]);
$this->assertCount(2, $json);
$this->assertCount(2, $json['errors']);
// Admin user - a valid bonus
$post = ['amount' => '50', 'description' => 'A bonus'];
$response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The bonus has been added to the wallet successfully.', $json['message']);
$this->assertSame($balance += 5000, $json['balance']);
$this->assertSame($balance, $wallet->fresh()->balance);
$transaction = Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_AWARD)->first();
$this->assertSame($post['description'], $transaction->description);
$this->assertSame(5000, $transaction->amount);
$this->assertSame($admin->email, $transaction->user_email);
// Admin user - a valid penalty
$post = ['amount' => '-40', 'description' => 'A penalty'];
$response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The penalty has been added to the wallet successfully.', $json['message']);
$this->assertSame($balance -= 4000, $json['balance']);
$this->assertSame($balance, $wallet->fresh()->balance);
$transaction = Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_PENALTY)->first();
$this->assertSame($post['description'], $transaction->description);
$this->assertSame(4000, $transaction->amount);
$this->assertSame($admin->email, $transaction->user_email);
}
/**
* Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions)
*/
public function testTransactions(): void
{
// Note: Here we're testing only that the end-point works,
// and admin can get the transaction log, response details
// are tested in Feature/Controller/WalletsTest.php
$this->deleteTestUser('wallets-controller@kolabnow.com');
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$wallet = $user->wallets()->first();
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Non-admin
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(403);
// Create some sample transactions
$transactions = $this->createTestTransactions($wallet);
$transactions = array_reverse($transactions);
$pages = array_chunk($transactions, 10 /* page size*/);
// Get the 2nd page
$response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions?page=2");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(2, $json['page']);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(2, $json['list']);
foreach ($pages[1] as $idx => $transaction) {
$this->assertSame($transaction->id, $json['list'][$idx]['id']);
$this->assertSame($transaction->type, $json['list'][$idx]['type']);
$this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']);
$this->assertFalse($json['list'][$idx]['hasDetails']);
}
// The 'user' key is set only on the admin end-point
$this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']);
}
/**
* Test updating a wallet (PUT /api/v4/wallets/:id)
*/
public function testUpdate(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
$discount = Discount::where('code', 'TEST')->first();
// Non-admin user
$response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []);
$response->assertStatus(403);
// Admin user - setting a discount
$post = ['discount' => $discount->id];
$response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('User wallet updated successfully.', $json['message']);
$this->assertSame($wallet->id, $json['id']);
$this->assertSame($discount->discount, $json['discount']);
$this->assertSame($discount->id, $json['discount_id']);
$this->assertSame($discount->description, $json['discount_description']);
$this->assertSame($discount->id, $wallet->fresh()->discount->id);
// Admin user - removing a discount
$post = ['discount' => null];
$response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('User wallet updated successfully.', $json['message']);
$this->assertSame($wallet->id, $json['id']);
$this->assertSame(null, $json['discount_id']);
$this->assertTrue(empty($json['discount_description']));
$this->assertSame(null, $wallet->fresh()->discount);
}
}
diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php
index 6ce25227..7dd7ce31 100644
--- a/src/tests/Feature/Controller/AuthTest.php
+++ b/src/tests/Feature/Controller/AuthTest.php
@@ -1,166 +1,185 @@
deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestDomain('userscontroller.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestDomain('userscontroller.com');
parent::tearDown();
}
/**
* Test fetching current user info (/api/auth/info)
*/
public function testInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$response = $this->actingAs($user)->get("api/auth/info");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals($user->id, $json['id']);
$this->assertEquals($user->email, $json['email']);
$this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(is_array($json['aliases']));
+ $this->assertTrue(!isset($json['access_token']));
// Note: Details of the content are tested in testUserResponse()
+
+ // Test token refresh via the info request
+ // First we log in as we need the token (actingAs() will not work)
+ $post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
+ $response = $this->post("api/auth/login", $post);
+ $json = $response->json();
+ $response = $this->withHeaders(['Authorization' => 'Bearer ' . $json['access_token']])
+ ->get("api/auth/info?refresh_token=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('john@kolab.org', $json['email']);
+ $this->assertTrue(is_array($json['statusInfo']));
+ $this->assertTrue(is_array($json['settings']));
+ $this->assertTrue(is_array($json['aliases']));
+ $this->assertTrue(!empty($json['access_token']));
+ $this->assertTrue(!empty($json['expires_in']));
}
/**
* Test /api/auth/login
*/
public function testLogin(): string
{
// Request with no data
$response = $this->post("api/auth/login", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
// Request with invalid password
$post = ['email' => 'john@kolab.org', 'password' => 'wrong'];
$response = $this->post("api/auth/login", $post);
$response->assertStatus(401);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('Invalid username or password.', $json['message']);
// Valid user+password
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertTrue(!empty($json['access_token']));
$this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']);
$this->assertEquals('bearer', $json['token_type']);
// TODO: We have browser tests for 2FA but we should probably also test it here
return $json['access_token'];
}
/**
* Test /api/auth/logout
*
* @depends testLogin
*/
public function testLogout($token): void
{
// Request with no token, testing that it requires auth
$response = $this->post("api/auth/logout");
$response->assertStatus(401);
// Test the same using JSON mode
$response = $this->json('POST', "api/auth/logout", []);
$response->assertStatus(401);
// Request with valid token
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('Successfully logged out.', $json['message']);
// Check if it really destroyed the token?
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info");
$response->assertStatus(401);
}
/**
* Test /api/auth/refresh
*/
public function testRefresh(): void
{
// Request with no token, testing that it requires auth
$response = $this->post("api/auth/refresh");
$response->assertStatus(401);
// Test the same using JSON mode
$response = $this->json('POST', "api/auth/refresh", []);
$response->assertStatus(401);
// Login the user to get a valid token
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$response->assertStatus(200);
$json = $response->json();
$token = $json['access_token'];
// Request with a valid token
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/refresh");
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(!empty($json['access_token']));
$this->assertTrue($json['access_token'] != $token);
$this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']);
$this->assertEquals('bearer', $json['token_type']);
$new_token = $json['access_token'];
// TODO: Shall we invalidate the old token?
// And if the new token is working
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info");
$response->assertStatus(200);
}
}