Page MenuHomePhorge

D5655.1775246343.diff
No OneTemporary

Authored By
Unknown
Size
34 KB
Referenced Files
None
Subscribers
None

D5655.1775246343.diff

diff --git a/src/app/Device.php b/src/app/Device.php
--- a/src/app/Device.php
+++ b/src/app/Device.php
@@ -58,93 +58,135 @@
// Before we do anything let's make sure that a device can be assigned only
// to a plan with a device SKU in a package
if ($device_packages->count() != 1) {
- throw new \Exception("A device requires a plan with a device SKU");
+ throw new \Exception("A plan with a device SKU is required");
}
foreach ($device_packages as $package) {
$this->assignPackageAndWallet($package, $wallet);
}
+
+ // Push entitlements.updated_at to one year in the future
+ $threshold = (clone $this->created_at)->addYearWithoutOverflow();
+ if ($threshold > \now()) {
+ $this->entitlements()->each(static function ($entitlement) use ($threshold) {
+ $entitlement->updated_at = $threshold;
+ $entitlement->save();
+ });
+ }
}
/**
* Assign device to (another) real user account
+ *
+ * @param string $token Device token
+ * @param User $user User claiming the device ownership
*/
- public function bindTo(User $user): void
+ public static function claim(string $token, User $user): self
{
- $wallet = $user->wallets()->first();
-
- // TODO: What if the device is already used by another (real) user?
- DB::beginTransaction();
-
- // Remove existing user association
- $this->entitlements()->delete();
-
- $device_packages = [];
+ $plan = null;
// Existing user's plan
if ($plan_id = $user->getSetting('plan_id')) {
$plan = Plan::withObjectTenantContext($user)->find($plan_id);
+ }
- // Find packages with a device SKU in this plan
- $device_packages = !$plan ? collect([]) : $plan->packages->filter(static function ($package) {
- foreach ($package->skus as $sku) {
- if ($sku->handler_class::entitleableClass() == self::class) {
- return true;
- }
- }
+ // TODO: Get "default" device package and assign if none found above, or just use the device SKU?
- return false;
- });
+ if (!$plan || !$plan->hasSku(self::class)) {
+ throw new \Exception("A plan with a device SKU is required");
+ }
- if ($device_packages->count() != 1) {
- throw new \Exception("A device requires a plan with a device SKU");
- }
+ DB::beginTransaction();
+
+ $device = self::initDevice($token);
+ $device->assignPlan($plan, $user->wallets()->first());
+
+ DB::commit();
+
+ return $device;
+ }
+
+ /**
+ * Get a default plan for a device signup
+ *
+ * @param string|SignupToken $token Device token
+ * @param bool $isDevice Get plan for a device, rather than a user
+ */
+ public static function defaultPlan($token, bool $isDevice): ?Plan
+ {
+ if (is_string($token)) {
+ $token = SignupToken::find(strtoupper($token));
}
- // TODO: Get "default" device package and assign if none found above, or just use the device SKU?
+ if (empty($token)) {
+ return null;
+ }
- foreach ($device_packages as $package) {
- $this->assignPackageAndWallet($package, $wallet);
+ $plans = Plan::withEnvTenantContext()
+ ->where('mode', Plan::MODE_TOKEN)
+ ->whereIn('id', $token->plans)
+ // TODO: Instead of using a prefix match, we should check the plan SKUs
+ ->whereLike('title', $isDevice ? 'device-%' : 'user-%')
+ ->get()
+ ->keyBy('title');
+
+ if (!$plans->count()) {
+ return null;
}
- // Push entitlements.updated_at to one year from the first registration
- $threshold = (clone $this->created_at)->addYearWithoutOverflow();
- if ($threshold > \now()) {
- $this->entitlements()->each(static function ($entitlement) use ($threshold) {
- $entitlement->updated_at = $threshold;
- $entitlement->save();
- });
+ $defaults = $plans->filter(function ($plan, $key) {
+ return str_contains($key, 'default');
+ });
+
+ if ($defaults->count()) {
+ return $defaults->first();
}
- DB::commit();
+ return $plans->first();
}
/**
- * Signup a device
+ * Setup a device record for the token. Create one if needed.
+ * Warning: It also removes any existing association with a user.
+ *
+ * @param string $token Device token
*/
- public static function signup(string $token, Plan $plan, string $password): self
+ public static function initDevice(string $token): self
{
- DB::beginTransaction();
-
// Check if a device already exists
$device = self::withTrashed()->where('hash', $token)->first();
if ($device) {
// FIXME: Should we remove the user (if it's a role=device user)?
- // FIXME: Should we bail out if the device is used by a normal user?
- // Remove the device-to-wallet connection
- $device->entitlements()->delete();
+ // Remove the existing device-to-wallet connection
+ $device->entitlements()->each(function ($entitlement) {
+ $entitlement->delete();
+ });
// Undelete the device if needed
if ($device->trashed()) {
- $device->restore();
+ $device->withoutEvents(function () use ($device) {
+ $device->restore();
+ });
}
} else {
// Create a device
$device = self::create(['hash' => $token]);
}
+ return $device;
+ }
+
+ /**
+ * Signup a device
+ */
+ public static function signup(string $token, Plan $plan, string $password): self
+ {
+ DB::beginTransaction();
+
+ $device = self::initDevice($token);
+
// Create a special account
while (true) {
$user_id = Utils::uuidInt();
@@ -166,21 +208,10 @@
]);
// Assign the device via an entitlement to the user's wallet
- $device->assignPlan($plan, $wallet = $user->wallets()->first());
-
- // Push entitlements.updated_at to one year in the future
- $threshold = (clone $device->created_at)->addYearWithoutOverflow();
- if ($threshold > \now()) {
- $device->entitlements()->each(static function ($entitlement) use ($threshold) {
- $entitlement->updated_at = $threshold;
- $entitlement->save();
- });
- }
+ $device->assignPlan($plan, $user->wallets()->first());
// FIXME: Should this bump signup_tokens.counter, as it does for user signup?
- // TODO: Trigger payment mandate creation?
-
DB::commit();
return $device;
diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php
--- a/src/app/Http/Controllers/API/PasswordResetController.php
+++ b/src/app/Http/Controllers/API/PasswordResetController.php
@@ -13,7 +13,6 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
-use Illuminate\Support\Str;
/**
* Password reset API
@@ -93,14 +92,10 @@
}
// Validate the verification code
- $code = VerificationCode::where('code', $request->code)->where('active', true)->first();
-
- if (
- empty($code)
- || $code->isExpired()
- || $code->mode !== VerificationCode::MODE_PASSWORD
- || Str::upper($request->short_code) !== Str::upper($code->short_code)
- ) {
+ $code = VerificationCode::where('code', $request->code)->where('active', true)
+ ->where('mode', VerificationCode::MODE_PASSWORD)->first();
+
+ if (empty($code) || !$code->codeValidate($request->short_code)) {
$errors = ['short_code' => self::trans('validation.verificationcodeinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -2,10 +2,12 @@
namespace App\Http\Controllers\API;
+use App\Device;
use App\Discount;
use App\Domain;
use App\Group;
use App\Http\Controllers\Controller;
+use App\Http\Resources\DeviceInfoResource;
use App\Http\Resources\PlanResource;
use App\Jobs\Mail\SignupVerificationJob;
use App\Payment;
@@ -252,7 +254,7 @@
#[BodyParameter('domain', description: 'User domain namespace', type: 'string', required: true)]
#[BodyParameter('password', description: 'User password', type: 'string', required: true)]
#[BodyParameter('voucher', description: 'Voucher code', type: 'string')]
- #[BodyParameter('plan', description: 'Plan identifier (required when not using a verification code)', type: 'string')]
+ #[BodyParameter('plan', description: 'Plan identifier (required when not using a verification code or token)', type: 'string')]
#[BodyParameter('invitation', description: 'Signup invitation identifier', type: 'string')]
#[BodyParameter('token', description: 'Signup token (required for mode=token plans)', type: 'string')]
#[BodyParameter('first_name', description: 'First name', type: 'string')]
@@ -287,6 +289,14 @@
// Direct signup by token
if ($request->token) {
+ if (!$request->plan) {
+ $plan = Device::defaultPlan($request->token, false);
+ if ($plan) {
+ $request->merge(['plan' => $plan]);
+ unset($rules['plan']);
+ }
+ }
+
// This will validate the token and the plan mode
$rules['token'] = ['required', 'string', new SignupTokenRule($plan)];
}
@@ -377,7 +387,7 @@
#[BodyParameter('domain', description: 'User domain namespace', type: 'string', required: true)]
#[BodyParameter('password', description: 'User password', type: 'string', required: true)]
#[BodyParameter('voucher', description: 'Voucher code', type: 'string')]
- #[BodyParameter('plan', description: 'Plan identifier (required when not using a verification code)', type: 'string')]
+ #[BodyParameter('plan', description: 'Plan identifier (required when not using a verification code or token)', type: 'string')]
#[BodyParameter('invitation', description: 'Signup invitation identifier', type: 'string')]
#[BodyParameter('token', description: 'Signup token (required for mode=token plans)', type: 'string')]
#[BodyParameter('first_name', description: 'First name', type: 'string')]
@@ -467,9 +477,17 @@
}
}
- // Bump up counter on the signup token
if (!empty($request->settings['signup_token'])) {
- SignupToken::where('id', $request->settings['signup_token'])->increment('counter');
+ $token = $request->settings['signup_token'];
+
+ // Bump up counter on the signup token
+ SignupToken::where('id', $token)->increment('counter');
+
+ // Bind the device with the user
+ if ($request->plan->hasSku(Device::class)) {
+ $device = Device::initDevice($token);
+ $device->assignPlan($request->plan, $user->wallets()->first());
+ }
}
DB::commit();
@@ -480,6 +498,10 @@
$response->checkout = $this->mandateForPlan($request->plan, $request->discount, $user);
}
+ if (!empty($device)) {
+ $response->device = new DeviceInfoResource($device);
+ }
+
return $response;
}
diff --git a/src/app/Http/Controllers/API/V4/DeviceController.php b/src/app/Http/Controllers/API/V4/DeviceController.php
--- a/src/app/Http/Controllers/API/V4/DeviceController.php
+++ b/src/app/Http/Controllers/API/V4/DeviceController.php
@@ -30,16 +30,31 @@
}
$device = Device::where('hash', strtoupper($token))->first();
-
- if (empty($device)) {
- return $this->errorResponse(404);
+ $user = $this->guard()->user();
+
+ if ($device) {
+ $device_wallet = $device->wallet();
+ $user_wallet = $user->wallets()->first();
+
+ // Does the existing device already belong to this user?
+ if ($device_wallet && $device_wallet->id == $user_wallet->id) {
+ response()->json([
+ 'status' => 'success',
+ 'message' => self::trans('app.device-claim-success'),
+ ]);
+ }
+ } else {
+ if (!SignupToken::where('id', strtoupper($token))->exists()) {
+ return $this->errorResponse(404);
+ }
}
- $device->bindTo($this->guard()->user());
+ $device = Device::claim($token, $user);
return response()->json([
'status' => 'success',
'message' => self::trans('app.device-claim-success'),
+ 'device' => new DeviceInfoResource($device),
]);
}
@@ -106,38 +121,39 @@
*
* @unauthenticated
*/
- #[BodyParameter('plan', description: 'Plan title', type: 'string', required: true)]
+ #[BodyParameter('plan', description: 'Plan title', type: 'string')]
public function signup(Request $request, string $token)
{
- $v = Validator::make($request->all(), ['plan' => ['required', 'string']]);
-
- if ($v->fails()) {
- return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
- }
-
// Signup plan
- $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
+ if ($request->plan) {
+ $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
+ } else {
+ $plan = Device::defaultPlan($token, true);
+ }
if (!$plan) {
$errors = ['plan' => self::trans('validation.invalidvalue')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- $request->merge([
- 'plan' => $plan,
- 'token' => \strtoupper($token),
- ]);
-
- // Validate input
- $v = Validator::make(
- $request->all(),
- [
- 'token' => ['required', 'string', new SignupTokenRule($plan)],
- ]
- );
-
- if ($v->fails()) {
- return response()->json(['status' => 'error', /* @var array */ 'errors' => $v->errors()], 422);
+ // Validate token (needed when using non-default plan)
+ if ($request->plan) {
+ $request->merge([
+ 'plan' => $plan,
+ 'token' => \strtoupper($token),
+ ]);
+
+ // Validate input
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'token' => ['required', 'string', new SignupTokenRule($plan)],
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', /* @var array */ 'errors' => $v->errors()], 422);
+ }
}
// TODO: Validate that the plan is device-only, don't accept a user plan here
@@ -157,6 +173,8 @@
'password' => $password,
];
+ $response->device = new DeviceInfoResource($device);
+
return $response;
}
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -82,12 +82,7 @@
return $this->errorResponse(403);
}
- if (
- empty($code)
- || $code->isExpired()
- || Str::upper($request->short_code) !== Str::upper($code->short_code)
- || empty($message = $code->applyAction())
- ) {
+ if (empty($code) || !$code->codeValidate($request->short_code, $message)) {
$errors = ['short_code' => self::trans('validation.verificationcodeinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
diff --git a/src/app/Http/Resources/AuthResource.php b/src/app/Http/Resources/AuthResource.php
--- a/src/app/Http/Resources/AuthResource.php
+++ b/src/app/Http/Resources/AuthResource.php
@@ -14,6 +14,7 @@
public ?int $user_id = null;
public ?array $checkout = null;
public ?array $credentials = null;
+ public ?DeviceInfoResource $device = null;
private ?UserInfoResource $userinfo = null;
@@ -50,6 +51,8 @@
'checkout' => $this->when(isset($this->checkout), $this->checkout),
// @var array New user credentials (on device signup)
'credentials' => $this->when(isset($this->credentials), $this->credentials),
+ // @var DeviceInfoResource Device information (on device signup)
+ 'device' => $this->when(isset($this->device), $this->device),
// @var int User identifier
'id' => $this->user_id,
// @var UserInfoResource User information
diff --git a/src/app/Http/Resources/DeviceInfoResource.php b/src/app/Http/Resources/DeviceInfoResource.php
--- a/src/app/Http/Resources/DeviceInfoResource.php
+++ b/src/app/Http/Resources/DeviceInfoResource.php
@@ -20,10 +20,26 @@
{
return [
/*
- * @var string Device registration date-time
+ * @var string Device registration date
* @format date-time
*/
'created_at' => (string) $this->resource->created_at,
+
+ // Number of free months left
+ 'freeMonths' => $this->freeMonths(),
];
}
+
+ /**
+ * Calculate number of free months left
+ */
+ private function freeMonths(): int
+ {
+ $until = (clone $this->created_at)->addYearWithoutOverflow()->floorMonth();
+ $now = (clone \now())->floorMonth();
+
+ $months = $now->diffInMonths($until);
+
+ return max(0, $months); // No negative values
+ }
}
diff --git a/src/app/Http/Resources/PlanResource.php b/src/app/Http/Resources/PlanResource.php
--- a/src/app/Http/Resources/PlanResource.php
+++ b/src/app/Http/Resources/PlanResource.php
@@ -24,6 +24,8 @@
}
return [
+ // @var string Plan identifier
+ 'id' => $this->resource->id,
// @var string Plan title (identifier)
'title' => $this->resource->title,
// @var string Plan name
diff --git a/src/app/Http/Resources/UserInfoResource.php b/src/app/Http/Resources/UserInfoResource.php
--- a/src/app/Http/Resources/UserInfoResource.php
+++ b/src/app/Http/Resources/UserInfoResource.php
@@ -24,6 +24,9 @@
'last_name',
'organization',
'phone',
+ 'password_expired',
+ 'debug',
+ 'plan_id',
];
/**
@@ -37,8 +40,7 @@
$isLocked = !$this->resource->isActive() && $wallet->plan()?->mode == Plan::MODE_MANDATE;
// Settings
- $keys = array_merge(self::USER_SETTINGS, ['password_expired', 'debug']);
- $settings = $this->resource->settings()->whereIn('key', $keys)->pluck('value', 'key')->all();
+ $settings = $this->resource->settings()->whereIn('key', self::USER_SETTINGS)->pluck('value', 'key')->all();
return [
$this->merge(parent::toArray($request)),
diff --git a/src/app/Plan.php b/src/app/Plan.php
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -134,4 +134,20 @@
return false;
}
+
+ /**
+ * Checks if the plan has a SKU assigned.
+ */
+ public function hasSku(string $class): bool
+ {
+ foreach ($this->packages as $package) {
+ foreach ($package->skus as $sku) {
+ if ($sku->handler_class::entitleableClass() == $class) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
}
diff --git a/src/app/SignupCode.php b/src/app/SignupCode.php
--- a/src/app/SignupCode.php
+++ b/src/app/SignupCode.php
@@ -88,6 +88,12 @@
*/
public static function generateShortCode(): string
{
+ $test_code = \config('app.test_verification_code');
+
+ if (strlen($test_code)) {
+ return $test_code;
+ }
+
$code_length = env('SIGNUP_CODE_LENGTH', self::SHORTCODE_LENGTH);
return Utils::randStr($code_length);
diff --git a/src/app/VerificationCode.php b/src/app/VerificationCode.php
--- a/src/app/VerificationCode.php
+++ b/src/app/VerificationCode.php
@@ -55,14 +55,14 @@
/**
* Apply action on verified code.
*/
- public function applyAction(): ?string
+ protected function applyAction(&$message): bool
{
switch ($this->mode) {
case self::MODE_EMAIL:
$settings = $this->user->getSettings(['external_email_new', 'external_email_code']);
if ($settings['external_email_code'] != $this->code) {
- return null;
+ return false;
}
$this->user->setSettings([
@@ -72,11 +72,30 @@
]);
$this->delete();
+ $message = \trans('app.code-verified-email');
+ break;
+ }
+
+ return true;
+ }
- return \trans('app.code-verified-email');
- default:
- return null;
+ /**
+ * Validate a code and execute defined action if valid.
+ *
+ * @param string $short_code Short code
+ * @param ?string $message Success message
+ */
+ public function codeValidate(string $short_code, &$message = null): bool
+ {
+ if (!$this->active || $this->isExpired()) {
+ return false;
}
+
+ if (\strtoupper($short_code) !== \strtoupper($this->short_code)) {
+ return false;
+ }
+
+ return $this->applyAction($message);
}
/**
@@ -84,6 +103,12 @@
*/
public static function generateShortCode(): string
{
+ $test_code = \config('app.test_verification_code');
+
+ if (strlen($test_code)) {
+ return $test_code;
+ }
+
$code_length = env('VERIFICATION_CODE_LENGTH', self::SHORTCODE_LENGTH);
return Utils::randStr($code_length, 1, '', '1234567890');
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -288,4 +288,8 @@
['mail'],
\env('IMAP_WITH_GROUPWARE_DEFAULT_FOLDERS', true) ? ['event', 'contact', 'task', 'note', 'file'] : []
),
+
+ // Defines a short code that is used for every verification code
+ // Warning: Don't use it in production!
+ 'test_verification_code' => (string) env('TEST_VERIFICATION_CODE', ''),
];
diff --git a/src/tests/Feature/Controller/DeviceTest.php b/src/tests/Feature/Controller/DeviceTest.php
--- a/src/tests/Feature/Controller/DeviceTest.php
+++ b/src/tests/Feature/Controller/DeviceTest.php
@@ -24,7 +24,7 @@
$this->hash = str_repeat('1', 64);
Device::query()->forceDelete();
- Plan::where('title', 'test')->delete();
+ Plan::whereIn('title', ['device-test', 'device-test-default'])->delete();
Package::where('title', 'test')->delete();
User::where('role', User::ROLE_DEVICE)->forceDelete();
$this->deleteTestUser('jane@kolabnow.com');
@@ -34,7 +34,7 @@
protected function tearDown(): void
{
SignupToken::query()->delete();
- Plan::where('title', 'test')->delete();
+ Plan::whereIn('title', ['device-test', 'device-test-default'])->delete();
Package::where('title', 'test')->delete();
$this->deleteTestUser('jane@kolabnow.com');
User::where('role', User::ROLE_DEVICE)->forceDelete();
@@ -65,6 +65,28 @@
$json = $response->json();
$this->assertStringContainsString('2025-02-02', $json['created_at']);
+ $this->assertSame(12, $json['freeMonths']);
+
+ // Assert freeMonths after 2 months
+ Carbon::setTestNow(Carbon::createFromDate(2025, 4, 2));
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $json = $response->json();
+
+ $this->assertSame(10, $json['freeMonths']);
+
+ // Assert freeMonths after 12 months
+ Carbon::setTestNow(Carbon::createFromDate(2026, 2, 2));
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $json = $response->json();
+
+ $this->assertSame(0, $json['freeMonths']);
+
+ // Assert freeMonths after 13 months
+ Carbon::setTestNow(Carbon::createFromDate(2026, 3, 2));
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $json = $response->json();
+
+ $this->assertSame(0, $json['freeMonths']);
}
/**
@@ -95,6 +117,40 @@
$this->assertSame('success', $json['status']);
$this->assertSame('The device has been claimed successfully.', $json['message']);
+ $this->assertStringContainsString('2025-02-02', $json['device']['created_at']);
+ $this->assertSame(12, $json['device']['freeMonths']);
+ $device = Device::where('hash', $this->hash)->first();
+ $this->assertCount(1, $device->entitlements);
+ $this->assertSame($user->wallets()->first()->id, $device->entitlements[0]->wallet_id);
+
+ // Claim a soft-deleted device, no token registered
+ $device->delete();
+ $response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
+ $response->assertStatus(404);
+
+ // Claim a soft-deleted device, token registered
+ SignupToken::create(['id' => $this->hash, 'plans' => [$plan->id]]);
+ $response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The device has been claimed successfully.', $json['message']);
+ $device = Device::where('hash', $this->hash)->first();
+ $this->assertCount(1, $device->entitlements);
+ $this->assertSame($user->wallets()->first()->id, $device->entitlements[0]->wallet_id);
+
+ // Claim a non-existing device
+ $device->forceDelete();
+ $response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The device has been claimed successfully.', $json['message']);
+ $device = Device::where('hash', $this->hash)->first();
$this->assertCount(1, $device->entitlements);
$this->assertSame($user->wallets()->first()->id, $device->entitlements[0]->wallet_id);
@@ -111,7 +167,7 @@
$response->assertStatus(404);
$plan = Plan::create([
- 'title' => 'test',
+ 'title' => 'device-test',
'name' => 'Test',
'description' => 'Test',
'mode' => Plan::MODE_TOKEN,
@@ -138,7 +194,7 @@
{
$sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
$plan = Plan::create([
- 'title' => 'test',
+ 'title' => 'device-test-default',
'name' => 'Test',
'description' => 'Test',
'mode' => Plan::MODE_TOKEN,
@@ -152,16 +208,6 @@
$plan->packages()->saveMany([$package]);
$package->skus()->saveMany([$sku]);
- // Signup, missing plan
- $post = [];
- $response = $this->post("api/v4/device/{$this->hash}/signup", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertSame('error', $json['status']);
- $this->assertSame(['plan' => ['The plan field is required.']], $json['errors']);
-
// Signup, invalid plan
$post = ['plan' => 'invalid'];
$response = $this->post("api/v4/device/{$this->hash}/signup", $post);
@@ -194,6 +240,8 @@
$this->assertSame('bearer', $json['token_type']);
$this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0);
$this->assertNotEmpty($json['access_token']);
+ $this->assertStringContainsString('2025-02-02', $json['device']['created_at']);
+ $this->assertSame(12, $json['device']['freeMonths']);
$device = Device::where('hash', $this->hash)->first();
$account = $device->account;
@@ -215,7 +263,8 @@
// Note: without this finding the proper wallet may not work because of how Device::wallet() works
Carbon::setTestNow(Carbon::createFromDate(2025, 3, 4));
- // Signup again
+ // Signup again (w/o a plan now)
+ unset($post['plan']);
$response = $this->post("api/v4/device/{$this->hash}/signup", $post);
$response->assertStatus(200);
@@ -245,7 +294,7 @@
{
$sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
$plan = Plan::create([
- 'title' => 'test',
+ 'title' => 'device-test',
'name' => 'Test',
'description' => 'Test',
'mode' => Plan::MODE_TOKEN,
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature\Controller;
+use App\Device;
use App\Discount;
use App\Domain;
use App\Http\Controllers\API\SignupController;
@@ -14,8 +15,10 @@
use App\SignupCode;
use App\SignupInvitation as SI;
use App\SignupToken;
+use App\Sku;
use App\User;
use App\VatRate;
+use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -43,7 +46,9 @@
SI::truncate();
SignupToken::truncate();
- Plan::where('title', 'test')->delete();
+ Plan::whereIn('title', ['test', 'user-test'])->delete();
+ Package::where('title', 'test')->delete();
+ Device::query()->forceDelete();
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
VatRate::query()->delete();
ReferralProgram::query()->delete();
@@ -63,7 +68,9 @@
SI::truncate();
SignupToken::truncate();
- Plan::where('title', 'test')->delete();
+ Plan::whereIn('title', ['test', 'user-test'])->delete();
+ Package::where('title', 'test')->delete();
+ Device::query()->forceDelete();
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
VatRate::query()->delete();
ReferralProgram::query()->delete();
@@ -994,8 +1001,9 @@
{
Queue::fake();
+ $sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
$plan = Plan::create([
- 'title' => 'test',
+ 'title' => 'user-test',
'name' => 'Test Account',
'description' => 'Test',
'free_months' => 1,
@@ -1003,6 +1011,16 @@
'discount_rate' => 0,
'mode' => Plan::MODE_TOKEN,
]);
+ $package = Package::create([
+ 'title' => 'test',
+ 'name' => 'Device Account',
+ 'description' => 'A device account.',
+ 'discount_rate' => 0,
+ ]);
+ $plan->packages()->saveMany([$package]);
+ $package->skus()->saveMany([$sku]);
+
+ Carbon::setTestNow(Carbon::createFromDate(2025, 2, 2));
$post = [
'plan' => $plan->title,
@@ -1023,7 +1041,6 @@
// Test valid token
$token = SignupToken::create(['id' => 'abc', 'plans' => [$plan->id]]);
- $post['plan'] = $plan->title;
$response = $this->post('/api/auth/signup', $post);
$response->assertStatus(200);
@@ -1041,6 +1058,45 @@
// Token's counter bumped up
$this->assertSame(1, $token->fresh()->counter);
+
+ // Device registered
+ $device = Device::where('hash', $token->id)->first();
+ $this->assertSame($device->account->email, $user->email);
+ $entitlements = $device->wallet()->entitlements()->get();
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($sku->id, $entitlements[0]->sku_id);
+ $this->assertStringContainsString('2026-02-02', $entitlements[0]->updated_at->toDateString());
+ $this->assertStringContainsString('2025-02-02', $json['device']['created_at']);
+ $this->assertSame(12, $json['device']['freeMonths']);
+
+ // Signup a new user with the same token/device (use default plan)
+ Carbon::setTestNow(Carbon::createFromDate(2025, 4, 2));
+ $post = [
+ 'token' => 'abc',
+ 'login' => 'signuplogin',
+ 'domain' => $this->domain,
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
+ ];
+
+ $response = $this->post('/api/auth/signup', $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $user2 = User::where('email', $post['login'] . '@' . $post['domain'])->first();
+ $this->assertSame($plan->id, $user->getSetting('plan_id'));
+ $this->assertSame($token->id, $user->getSetting('signup_token'));
+ $this->assertSame(2, $token->fresh()->counter);
+
+ $device->refresh();
+ $this->assertSame($device->account->email, $user2->email);
+ $entitlements = $device->entitlements()->get();
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($sku->id, $entitlements[0]->sku_id);
+ $this->assertStringContainsString('2026-02-02', $entitlements[0]->updated_at->toDateString());
+ $this->assertCount(1, $user->wallets()->first()->entitlements()->withTrashed()
+ ->where('sku_id', $sku->id)->whereNotNull('deleted_at')->get());
}
/**
diff --git a/src/tests/Feature/PlanTest.php b/src/tests/Feature/PlanTest.php
--- a/src/tests/Feature/PlanTest.php
+++ b/src/tests/Feature/PlanTest.php
@@ -2,8 +2,10 @@
namespace Tests\Feature;
+use App\Domain;
use App\Plan;
use App\Tenant;
+use App\User;
use Tests\TestCase;
class PlanTest extends TestCase
@@ -74,6 +76,22 @@
$this->assertTrue($plan->hasDomain() === true);
}
+ /**
+ * Tests for Plan::hasSku()
+ */
+ public function testHasSku(): void
+ {
+ $plan = Plan::where('title', 'individual')->first();
+
+ $this->assertFalse($plan->hasSku(Domain::class));
+ $this->assertTrue($plan->hasSku(User::class));
+
+ $plan = Plan::where('title', 'group')->first();
+
+ $this->assertTrue($plan->hasSku(Domain::class));
+ $this->assertTrue($plan->hasSku(User::class));
+ }
+
/**
* Test for a plan's cost.
*/

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 7:59 PM (11 h, 43 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18826201
Default Alt Text
D5655.1775246343.diff (34 KB)

Event Timeline