@fleetbase/fleetops-engine 0.6.67 → 0.6.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE.md +11 -15
- package/addon/components/contact/form.hbs +13 -2
- package/addon/components/customer/details.hbs +17 -17
- package/addon/components/customer/form.hbs +13 -2
- package/addon/components/customer/form.js +0 -56
- package/addon/components/driver/details.hbs +13 -19
- package/addon/components/driver/form.hbs +20 -35
- package/addon/components/driver/form.js +0 -58
- package/addon/components/fuel-integration/form.hbs +2 -2
- package/addon/components/fuel-integration/form.js +8 -0
- package/addon/components/fuel-integration/hub.js +1 -1
- package/addon/components/modals/driver-form.hbs +2 -2
- package/addon/components/modals/reset-profile-credentials.hbs +23 -0
- package/addon/components/modals/reset-profile-credentials.js +70 -0
- package/addon/components/profile-identity-hint.hbs +13 -0
- package/addon/components/profile-identity-hint.js +49 -0
- package/addon/controllers/management/drivers/index.js +4 -0
- package/addon/extension.js +4 -2
- package/addon/services/contact-actions.js +50 -89
- package/addon/services/driver-actions.js +98 -0
- package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
- package/addon/utils/fuel-transaction.js +2 -0
- package/addon/utils/profile-login-actions.js +172 -0
- package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
- package/app/components/profile-identity-hint.js +1 -0
- package/composer.json +1 -1
- package/docs/SAFEE.md +5 -0
- package/docs/TELEMATICS_QUEUES.md +59 -8
- package/extension.json +1 -1
- package/package.json +3 -3
- package/scripts/pest-bootstrap.php +12 -0
- package/server/config/fuel-providers.php +53 -0
- package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
- package/server/resources/views/mail/driver-credentials.blade.php +21 -0
- package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
- package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
- package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
- package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
- package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
- package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
- package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
- package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
- package/server/src/Http/Resources/v1/Contact.php +3 -0
- package/server/src/Http/Resources/v1/Driver.php +3 -0
- package/server/src/Jobs/PollTelematicTelemetry.php +36 -0
- package/server/src/Jobs/SyncTelematicDevicesJob.php +2 -2
- package/server/src/Mail/DriverCredentialsMail.php +55 -0
- package/server/src/Models/Contact.php +55 -90
- package/server/src/Models/Sensor.php +3 -5
- package/server/src/Observers/ContactObserver.php +2 -22
- package/server/src/Observers/DriverObserver.php +5 -6
- package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
- package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
- package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
- package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
- package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
- package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
- package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
- package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
- package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
- package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
- package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
- package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
- package/server/src/Support/ProfileAccountManager.php +405 -0
- package/server/src/Support/Telematics/TelematicService.php +6 -4
- package/server/src/Support/Telematics/Telemetry/Inbox.php +27 -9
- package/server/src/routes.php +5 -0
- package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
- package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
- package/server/tests/ApiDriverControllerContractsTest.php +102 -20
- package/server/tests/CommandContractsTest.php +4 -4
- package/server/tests/ControllerHelperContractsTest.php +3 -11
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
- package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
- package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
- package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
- package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
- package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
- package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
- package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
- package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
- package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
- package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
- package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +88 -4
- package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
- package/server/tests/ModelAccessorContractsTest.php +82 -20
- package/server/tests/ObserverContractsTest.php +27 -2
- package/server/tests/RequestContractsTest.php +19 -11
- package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
- package/server/tests/TelematicControllerContractsTest.php +22 -4
- package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
- package/server/tests/Unit/Models/ContactTest.php +48 -5
- package/server/tests/Unit/Models/SensorTest.php +2 -2
- package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
- package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
- package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
- package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
- package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
- package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
- package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
- package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
- package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
- package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
- package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
- package/translations/en-us.yaml +37 -2
- package/translations/uk-ua.yaml +3475 -0
- package/addon/components/modals/reset-customer-credentials.hbs +0 -18
- package/addon/components/modals/reset-customer-credentials.js +0 -55
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
if (!function_exists('Fleetbase\\Observers\\event')) {
|
|
4
|
+
eval('namespace Fleetbase\\Observers; function event($event = null, $payload = []) { return []; }');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
if (!function_exists('Fleetbase\\FleetOps\\Observers\\event')) {
|
|
8
|
+
eval('namespace Fleetbase\\FleetOps\\Observers; function event($event = null, $payload = []) { return []; }');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!function_exists('__')) {
|
|
12
|
+
function __($key = null, $replace = [], $locale = null)
|
|
13
|
+
{
|
|
14
|
+
return $key;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!Illuminate\Support\Str::hasMacro('humanize')) {
|
|
19
|
+
Illuminate\Support\Str::macro('humanize', fn ($value, $uppercase = true) => str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
|
|
23
|
+
use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
|
|
24
|
+
use Fleetbase\FleetOps\Mail\DriverCredentialsMail;
|
|
25
|
+
use Fleetbase\FleetOps\Models\Contact;
|
|
26
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
27
|
+
use Fleetbase\FleetOps\Support\ProfileAccountManager;
|
|
28
|
+
use Fleetbase\Models\CompanyUser;
|
|
29
|
+
use Fleetbase\Models\User;
|
|
30
|
+
use Fleetbase\Services\SmsService;
|
|
31
|
+
use Illuminate\Database\ConnectionResolver;
|
|
32
|
+
use Illuminate\Database\Eloquent\Model as EloquentModel;
|
|
33
|
+
use Illuminate\Database\SQLiteConnection;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Covers ProfileAccountManager against SQLite with real spatie roles: account
|
|
37
|
+
* classification, identity lookups, linking a staff member or a managed
|
|
38
|
+
* account of the same type, creating managed accounts without invites, proxy
|
|
39
|
+
* field sync, releasing an account when its profile is deleted, and sending
|
|
40
|
+
* credentials by email or SMS.
|
|
41
|
+
*/
|
|
42
|
+
class FleetOpsProfileAccountSmsFake extends SmsService
|
|
43
|
+
{
|
|
44
|
+
public array $sent = [];
|
|
45
|
+
public array $result = ['success' => true];
|
|
46
|
+
|
|
47
|
+
public function __construct()
|
|
48
|
+
{
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public function send(string $to, string $text, array $options = [], ?string $provider = null): array
|
|
52
|
+
{
|
|
53
|
+
$this->sent[] = [$to, $text];
|
|
54
|
+
|
|
55
|
+
return $this->result;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fleetopsProfileAccountBoot(): SQLiteConnection
|
|
60
|
+
{
|
|
61
|
+
// Model uuid hooks bind to whichever dispatcher exists when the class boots
|
|
62
|
+
if (!EloquentModel::getEventDispatcher()) {
|
|
63
|
+
EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$connection = new SQLiteConnection(new PDO('sqlite::memory:'));
|
|
67
|
+
$resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
68
|
+
$resolver->setDefaultConnection('mysql');
|
|
69
|
+
EloquentModel::setConnectionResolver($resolver);
|
|
70
|
+
app()->instance('db', new class($connection) {
|
|
71
|
+
public function __construct(public SQLiteConnection $c)
|
|
72
|
+
{
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public function connection($name = null): SQLiteConnection
|
|
76
|
+
{
|
|
77
|
+
return $this->c;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public function __call($method, $arguments)
|
|
81
|
+
{
|
|
82
|
+
return $this->c->{$method}(...$arguments);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
app()->instance('db.schema', $connection->getSchemaBuilder());
|
|
86
|
+
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
|
|
87
|
+
app()->instance('responsecache', new class {
|
|
88
|
+
public function __call($method, $arguments)
|
|
89
|
+
{
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
|
|
94
|
+
public function info($hashedValue): array
|
|
95
|
+
{
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public function make($value, array $options = []): string
|
|
100
|
+
{
|
|
101
|
+
return 'hashed:' . $value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public function check($value, $hashedValue, array $options = []): bool
|
|
105
|
+
{
|
|
106
|
+
return 'hashed:' . $value === $hashedValue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public function needsRehash($hashedValue, array $options = []): bool
|
|
110
|
+
{
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
|
|
115
|
+
|
|
116
|
+
Illuminate\Support\Facades\Mail::swap(new class {
|
|
117
|
+
public array $sent = [];
|
|
118
|
+
public array $to = [];
|
|
119
|
+
|
|
120
|
+
public function to($users)
|
|
121
|
+
{
|
|
122
|
+
$this->to[] = $users;
|
|
123
|
+
|
|
124
|
+
return $this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public function send($mailable)
|
|
128
|
+
{
|
|
129
|
+
$this->sent[] = $mailable;
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
$sms = new FleetOpsProfileAccountSmsFake();
|
|
136
|
+
app()->instance(SmsService::class, $sms);
|
|
137
|
+
|
|
138
|
+
$schema = $connection->getSchemaBuilder();
|
|
139
|
+
$tables = [
|
|
140
|
+
'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'status', 'type', 'username', 'avatar_uuid', 'slug', 'timezone', 'country', 'ip_address', 'meta', 'last_login', '_key'],
|
|
141
|
+
'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'],
|
|
142
|
+
'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'type'],
|
|
143
|
+
'companies' => ['uuid', 'public_id', 'name', 'owner_uuid', 'timezone'],
|
|
144
|
+
'company_users' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'status', '_key'],
|
|
145
|
+
'permissions' => ['name', 'guard_name', 'service', 'description'],
|
|
146
|
+
'roles' => ['uuid', 'public_id', 'name', 'guard_name', 'company_uuid', 'service', 'description', '_key'],
|
|
147
|
+
'model_has_roles' => ['role_id', 'model_type', 'model_uuid'],
|
|
148
|
+
'model_has_permissions' => ['permission_id', 'model_type', 'model_uuid'],
|
|
149
|
+
'role_has_permissions' => ['permission_id', 'role_id'],
|
|
150
|
+
];
|
|
151
|
+
foreach ($tables as $table => $columns) {
|
|
152
|
+
$schema->create($table, function ($blueprint) use ($columns) {
|
|
153
|
+
$blueprint->increments('id');
|
|
154
|
+
foreach ($columns as $column) {
|
|
155
|
+
$blueprint->string($column)->nullable();
|
|
156
|
+
}
|
|
157
|
+
$blueprint->timestamps();
|
|
158
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
app()->instance('cache', new class {
|
|
163
|
+
public function tags($tags = null)
|
|
164
|
+
{
|
|
165
|
+
return $this;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public function flush()
|
|
169
|
+
{
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public function remember($key, $ttl, $callback)
|
|
174
|
+
{
|
|
175
|
+
return $callback();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public function store($name = null)
|
|
179
|
+
{
|
|
180
|
+
return $this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
public function __call($method, $arguments)
|
|
184
|
+
{
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
Illuminate\Support\Facades\Cache::clearResolvedInstance('cache');
|
|
189
|
+
config()->set('cache.default', 'array');
|
|
190
|
+
config()->set('cache.stores.array', ['driver' => 'array']);
|
|
191
|
+
config()->set('permission.cache.expiration_time', 60);
|
|
192
|
+
config()->set('permission.cache.key', 'spatie.permission.cache');
|
|
193
|
+
config()->set('permission.cache.store', 'default');
|
|
194
|
+
config()->set('permission.models.permission', Fleetbase\Models\Permission::class);
|
|
195
|
+
config()->set('permission.models.role', Fleetbase\Models\Role::class);
|
|
196
|
+
config()->set('permission.table_names', ['roles' => 'roles', 'permissions' => 'permissions', 'model_has_permissions' => 'model_has_permissions', 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions']);
|
|
197
|
+
config()->set('permission.column_names', ['role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_uuid', 'team_foreign_key' => 'team_id']);
|
|
198
|
+
config()->set('permission.teams', false);
|
|
199
|
+
config()->set('permission.events_enabled', false);
|
|
200
|
+
$cacheManager = new Illuminate\Cache\CacheManager(app());
|
|
201
|
+
app()->instance(Illuminate\Cache\CacheManager::class, $cacheManager);
|
|
202
|
+
app()->instance(Spatie\Permission\PermissionRegistrar::class, new Spatie\Permission\PermissionRegistrar($cacheManager));
|
|
203
|
+
config()->set('activitylog.enabled', false);
|
|
204
|
+
config()->set('activitylog.default_auth_driver', 'web');
|
|
205
|
+
config()->set('auth.defaults.guard', 'web');
|
|
206
|
+
config()->set('app.name', 'Fleetbase');
|
|
207
|
+
app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
|
|
208
|
+
|
|
209
|
+
session(['company' => 'company-1']);
|
|
210
|
+
$connection->table('companies')->insert([
|
|
211
|
+
['uuid' => 'company-1', 'public_id' => 'company_one', 'name' => 'Acme', 'timezone' => 'Asia/Singapore'],
|
|
212
|
+
['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Globex', 'timezone' => null],
|
|
213
|
+
]);
|
|
214
|
+
$roles = [];
|
|
215
|
+
foreach (['company-1', 'company-2'] as $company) {
|
|
216
|
+
foreach (['Driver', 'Fleet-Ops Contact', 'Fleet-Ops Customer'] as $name) {
|
|
217
|
+
foreach (['web', 'sanctum'] as $guard) {
|
|
218
|
+
$roles[] = ['uuid' => $company . $name . $guard, 'name' => $name, 'guard_name' => $guard, 'company_uuid' => $company];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
$connection->table('roles')->insert($roles);
|
|
223
|
+
|
|
224
|
+
return $connection;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function fleetopsProfileAccountRoles(SQLiteConnection $connection, string $userUuid, string $companyUuid): array
|
|
228
|
+
{
|
|
229
|
+
return $connection->table('model_has_roles')
|
|
230
|
+
->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
|
|
231
|
+
->join('company_users', 'company_users.uuid', '=', 'model_has_roles.model_uuid')
|
|
232
|
+
->where('company_users.user_uuid', $userUuid)
|
|
233
|
+
->where('company_users.company_uuid', $companyUuid)
|
|
234
|
+
->pluck('roles.name')
|
|
235
|
+
->unique()
|
|
236
|
+
->values()
|
|
237
|
+
->all();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Insert rows one at a time: a multi-row insert takes its column list from the
|
|
242
|
+
* first row, so rows with different keys would land in the wrong columns.
|
|
243
|
+
*/
|
|
244
|
+
function fleetopsProfileAccountRows(SQLiteConnection $connection, string $table, array $rows): void
|
|
245
|
+
{
|
|
246
|
+
foreach ($rows as $row) {
|
|
247
|
+
$connection->table($table)->insert($row);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function fleetopsProfileAccountUser(array $attributes): User
|
|
252
|
+
{
|
|
253
|
+
app('db')->connection()->table('users')->insert($attributes);
|
|
254
|
+
|
|
255
|
+
return User::where('uuid', $attributes['uuid'])->first();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
test('accounts are managed when typed driver contact or customer and staff otherwise', function () {
|
|
259
|
+
expect(ProfileAccountManager::isManagedAccount(null))->toBeFalse()
|
|
260
|
+
->and(ProfileAccountManager::isStaffAccount(null))->toBeFalse()
|
|
261
|
+
->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'driver']))->toBeTrue()
|
|
262
|
+
->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'contact']))->toBeTrue()
|
|
263
|
+
->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'customer']))->toBeTrue()
|
|
264
|
+
->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'customer']))->toBeFalse()
|
|
265
|
+
->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'user']))->toBeTrue()
|
|
266
|
+
->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'admin']))->toBeTrue()
|
|
267
|
+
->and(ProfileAccountManager::isStaffAccount((object) []))->toBeTrue()
|
|
268
|
+
->and(ProfileAccountManager::normalizeEmail(' Ada@Example.COM '))->toBe('ada@example.com')
|
|
269
|
+
->and(ProfileAccountManager::normalizeEmail(' '))->toBeNull()
|
|
270
|
+
->and(ProfileAccountManager::normalizeEmail(null))->toBeNull()
|
|
271
|
+
->and(ProfileAccountManager::normalizePhone(' +65 9123-4567 '))->toBe('+6591234567')
|
|
272
|
+
->and(ProfileAccountManager::normalizePhone(''))->toBeNull()
|
|
273
|
+
->and(ProfileAccountManager::normalizePhone(null))->toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('identity lookups match email or phone and skip ignored and deleted accounts', function () {
|
|
277
|
+
$connection = fleetopsProfileAccountBoot();
|
|
278
|
+
fleetopsProfileAccountRows($connection, 'users', [
|
|
279
|
+
['uuid' => 'user-email', 'email' => 'ada@example.com', 'type' => 'user'],
|
|
280
|
+
['uuid' => 'user-phone', 'phone' => '+6591234567', 'type' => 'driver'],
|
|
281
|
+
['uuid' => 'user-deleted', 'email' => 'gone@example.com', 'type' => 'driver', 'deleted_at' => '2026-01-01 00:00:00'],
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
expect(ProfileAccountManager::findAccountByIdentity(null, ' '))->toBeNull()
|
|
285
|
+
->and(ProfileAccountManager::findAccountByIdentity('ADA@example.com', null)?->uuid)->toBe('user-email')
|
|
286
|
+
->and(ProfileAccountManager::findAccountByIdentity(null, '+65 9123 4567')?->uuid)->toBe('user-phone')
|
|
287
|
+
->and(ProfileAccountManager::findAccountByIdentity('nobody@example.com', '+6591234567')?->uuid)->toBe('user-phone')
|
|
288
|
+
->and(ProfileAccountManager::findAccountByIdentity('ada@example.com', null, 'user-email'))->toBeNull()
|
|
289
|
+
->and(ProfileAccountManager::findAccountByIdentity('gone@example.com', null))->toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('lookup reports the staff member to link or why the identity is unavailable', function () {
|
|
293
|
+
$connection = fleetopsProfileAccountBoot();
|
|
294
|
+
fleetopsProfileAccountRows($connection, 'users', [
|
|
295
|
+
['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'],
|
|
296
|
+
['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'],
|
|
297
|
+
['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'],
|
|
298
|
+
['uuid' => 'current-1', 'company_uuid' => 'company-1', 'email' => 'me@example.com', 'type' => 'driver'],
|
|
299
|
+
]);
|
|
300
|
+
$current = User::where('uuid', 'current-1')->first();
|
|
301
|
+
|
|
302
|
+
$nothing = ProfileAccountManager::lookup('company-1', 'driver', 'free@example.com', null);
|
|
303
|
+
$staff = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null);
|
|
304
|
+
$outsider = ProfileAccountManager::lookup('company-1', 'driver', 'outsider@example.com', null);
|
|
305
|
+
$customer = ProfileAccountManager::lookup('company-1', 'customer', null, '+6590001111');
|
|
306
|
+
$sameType = ProfileAccountManager::lookup('company-1', 'driver', null, '+6590001111');
|
|
307
|
+
$taken = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null, $current);
|
|
308
|
+
$own = ProfileAccountManager::lookup('company-1', 'driver', 'me@example.com', null, $current);
|
|
309
|
+
|
|
310
|
+
expect($nothing)->toBe(['account' => null, 'staff' => null, 'conflict' => null])
|
|
311
|
+
->and($staff['staff']?->uuid)->toBe('staff-1')
|
|
312
|
+
->and($staff['conflict'])->toBeNull()
|
|
313
|
+
->and($outsider['staff'])->toBeNull()
|
|
314
|
+
->and($outsider['conflict'])->toBe('This email is already in use by another account.')
|
|
315
|
+
->and($customer['conflict'])->toBe('This phone number is already used by a driver.')
|
|
316
|
+
// A driver account of another organization is linked, not reported as staff
|
|
317
|
+
->and($sameType['account']?->uuid)->toBe('driver-1')
|
|
318
|
+
->and($sameType['staff'])->toBeNull()
|
|
319
|
+
->and($sameType['conflict'])->toBeNull()
|
|
320
|
+
// An existing profile can't swap its account for another one
|
|
321
|
+
->and($taken['conflict'])->toBe('This email is already in use by another account.')
|
|
322
|
+
->and($taken['staff'])->toBeNull()
|
|
323
|
+
->and($own['account'])->toBeNull();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('resolve for profile creates a managed account with the profile role and no invite', function () {
|
|
327
|
+
$connection = fleetopsProfileAccountBoot();
|
|
328
|
+
|
|
329
|
+
$driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Dana Driver', 'Dana@Example.com', '+65 9000 1111', [
|
|
330
|
+
'password' => 'chosen-secret',
|
|
331
|
+
'status' => 'active',
|
|
332
|
+
'avatar_uuid' => 'avatar-1',
|
|
333
|
+
'country' => 'SG',
|
|
334
|
+
'ip_address' => '127.0.0.1',
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
$row = $connection->table('users')->where('uuid', $driver->uuid)->first();
|
|
338
|
+
|
|
339
|
+
expect($driver->uuid)->not->toBeNull()
|
|
340
|
+
->and($row->type)->toBe('driver')
|
|
341
|
+
->and($row->email)->toBe('dana@example.com')
|
|
342
|
+
->and($row->phone)->toBe('+6590001111')
|
|
343
|
+
->and($row->password)->toBe('hashed:chosen-secret')
|
|
344
|
+
->and($row->status)->toBe('active')
|
|
345
|
+
->and($row->timezone)->toBe('Asia/Singapore')
|
|
346
|
+
->and($row->avatar_uuid)->toBe('avatar-1')
|
|
347
|
+
->and($row->company_uuid)->toBe('company-1')
|
|
348
|
+
->and(fleetopsProfileAccountRoles($connection, $driver->uuid, 'company-1'))->toBe(['Driver'])
|
|
349
|
+
->and($driver->getRelation('companyUser'))->toBeInstanceOf(CompanyUser::class);
|
|
350
|
+
|
|
351
|
+
// A nameless customer in a company without a timezone gets a generated
|
|
352
|
+
// username, a random password, the default status and the server timezone
|
|
353
|
+
$customer = ProfileAccountManager::resolveForProfile('company-2', 'customer', null, null, '+6590002222');
|
|
354
|
+
$customerRow = $connection->table('users')->where('uuid', $customer->uuid)->first();
|
|
355
|
+
|
|
356
|
+
expect($customerRow->type)->toBe('customer')
|
|
357
|
+
->and($customerRow->status)->toBe('pending')
|
|
358
|
+
->and($customerRow->name)->toBeNull()
|
|
359
|
+
->and($customerRow->username)->not->toBeEmpty()
|
|
360
|
+
->and($customerRow->password)->toStartWith('hashed:')
|
|
361
|
+
->and($customerRow->timezone)->toBe(date_default_timezone_get())
|
|
362
|
+
->and(fleetopsProfileAccountRoles($connection, $customer->uuid, 'company-2'))->toBe(['Fleet-Ops Customer']);
|
|
363
|
+
|
|
364
|
+
// A contact of an organization missing from the table is created without membership
|
|
365
|
+
$contact = ProfileAccountManager::createManagedAccount('company-missing', 'contact', 'Carl Contact', 'carl@example.com', null, ['timezone' => 'UTC']);
|
|
366
|
+
|
|
367
|
+
expect($connection->table('users')->where('uuid', $contact->uuid)->value('type'))->toBe('contact')
|
|
368
|
+
->and($connection->table('users')->where('uuid', $contact->uuid)->value('timezone'))->toBe('UTC')
|
|
369
|
+
->and($connection->table('company_users')->where('user_uuid', $contact->uuid)->count())->toBe(0);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('resolve for profile links staff members and managed accounts of the same type', function () {
|
|
373
|
+
$connection = fleetopsProfileAccountBoot();
|
|
374
|
+
fleetopsProfileAccountRows($connection, 'users', [
|
|
375
|
+
['uuid' => 'staff-1', 'company_uuid' => 'company-2', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'],
|
|
376
|
+
['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'],
|
|
377
|
+
['uuid' => 'driver-2', 'company_uuid' => null, 'phone' => '+6590003333', 'type' => 'driver'],
|
|
378
|
+
]);
|
|
379
|
+
// The staff member belongs to company-1 through a membership row
|
|
380
|
+
$connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']);
|
|
381
|
+
|
|
382
|
+
$staff = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Other Name', 'STAFF@example.com', null);
|
|
383
|
+
$driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Driver', null, '+6590001111');
|
|
384
|
+
$orphan = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Orphan', null, '+6590003333');
|
|
385
|
+
|
|
386
|
+
expect($staff->uuid)->toBe('staff-1')
|
|
387
|
+
->and($staff->type)->toBe('user')
|
|
388
|
+
->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0)
|
|
389
|
+
->and($driver->uuid)->toBe('driver-1')
|
|
390
|
+
->and(fleetopsProfileAccountRoles($connection, 'driver-1', 'company-1'))->toBe(['Driver'])
|
|
391
|
+
// The account keeps its original organization
|
|
392
|
+
->and($connection->table('users')->where('uuid', 'driver-1')->value('company_uuid'))->toBe('company-2')
|
|
393
|
+
// An account without an organization adopts this one
|
|
394
|
+
->and($orphan->uuid)->toBe('driver-2')
|
|
395
|
+
->and($connection->table('users')->where('uuid', 'driver-2')->value('company_uuid'))->toBe('company-1')
|
|
396
|
+
->and($connection->table('users')->count())->toBe(3);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('resolve for profile rejects accounts that cannot hold the profile', function () {
|
|
400
|
+
$connection = fleetopsProfileAccountBoot();
|
|
401
|
+
fleetopsProfileAccountRows($connection, 'users', [
|
|
402
|
+
['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'],
|
|
403
|
+
['uuid' => 'customer-1', 'company_uuid' => 'company-1', 'phone' => '+6590001111', 'type' => 'customer'],
|
|
404
|
+
['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'],
|
|
405
|
+
['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'email' => 'contact@example.com', 'type' => 'contact'],
|
|
406
|
+
]);
|
|
407
|
+
$connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1']);
|
|
408
|
+
$connection->table('contacts')->insert(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'user_uuid' => 'contact-1', 'type' => 'contact']);
|
|
409
|
+
|
|
410
|
+
$conflict = function (callable $callback): ?ProfileIdentityConflictException {
|
|
411
|
+
try {
|
|
412
|
+
$callback();
|
|
413
|
+
} catch (ProfileIdentityConflictException $exception) {
|
|
414
|
+
return $exception;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return null;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
$outsider = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'outsider@example.com', null));
|
|
421
|
+
$wrong = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'nobody@example.com', '+6590001111'));
|
|
422
|
+
$driver = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'driver@example.com', null));
|
|
423
|
+
$contact = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'contact', 'X', 'contact@example.com', null));
|
|
424
|
+
|
|
425
|
+
expect($outsider?->getMessage())->toBe('This email is already in use by another account.')
|
|
426
|
+
->and($outsider?->getField())->toBe('email')
|
|
427
|
+
->and($outsider?->getErrors())->toBe(['email' => ['This email is already in use by another account.']])
|
|
428
|
+
->and($wrong?->getMessage())->toBe('This phone number is already used by a customer.')
|
|
429
|
+
->and($wrong?->getField())->toBe('phone')
|
|
430
|
+
->and($driver?->getMessage())->toBe('A driver with this email already exists.')
|
|
431
|
+
->and($contact?->getMessage())->toBe('A contact with this email already exists.')
|
|
432
|
+
->and(ProfileAccountManager::hasProfileInCompany(User::where('uuid', 'driver-1')->first(), 'company-2', 'driver'))->toBeFalse()
|
|
433
|
+
->and(ProfileAccountManager::isCompanyMember(User::where('uuid', 'driver-1')->first(), null))->toBeFalse()
|
|
434
|
+
->and(new ProfileIdentityConflictException('Taken'))->getField()->toBe('email');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('sync proxy fields pushes normalized changes to managed accounts and only the name to staff', function () {
|
|
438
|
+
$connection = fleetopsProfileAccountBoot();
|
|
439
|
+
$connection->table('users')->insert(['uuid' => 'other-1', 'email' => 'taken@example.com', 'phone' => '+6599990000', 'type' => 'user']);
|
|
440
|
+
$managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'name' => 'Old', 'email' => 'old@example.com', 'phone' => '+6590000000', 'timezone' => 'UTC', 'type' => 'driver']);
|
|
441
|
+
$staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'phone' => '+6591111111', 'type' => 'user']);
|
|
442
|
+
|
|
443
|
+
expect(ProfileAccountManager::syncProxyFields(null, ['name' => 'Nobody']))->toBeFalse()
|
|
444
|
+
// Unchanged values and fields outside the proxy set are ignored
|
|
445
|
+
->and(ProfileAccountManager::syncProxyFields($managed, ['name' => 'Old', 'email' => 'OLD@example.com', 'type' => 'admin', 'timezone' => null]))->toBeFalse();
|
|
446
|
+
|
|
447
|
+
expect(ProfileAccountManager::syncProxyFields($managed, [
|
|
448
|
+
'name' => 'New',
|
|
449
|
+
'email' => ' New@Example.com ',
|
|
450
|
+
'phone' => '+65 9000 0001',
|
|
451
|
+
'timezone' => 'Asia/Singapore',
|
|
452
|
+
'password' => 'new-secret',
|
|
453
|
+
]))->toBeTrue();
|
|
454
|
+
|
|
455
|
+
$row = $connection->table('users')->where('uuid', 'managed-1')->first();
|
|
456
|
+
expect($row->name)->toBe('New')
|
|
457
|
+
->and($row->email)->toBe('new@example.com')
|
|
458
|
+
->and($row->phone)->toBe('+6590000001')
|
|
459
|
+
->and($row->timezone)->toBe('Asia/Singapore')
|
|
460
|
+
->and($row->password)->toBe('hashed:new-secret');
|
|
461
|
+
|
|
462
|
+
// Clearing the phone is a change the account takes
|
|
463
|
+
expect(ProfileAccountManager::syncProxyFields($managed, ['phone' => null]))->toBeTrue()
|
|
464
|
+
->and($connection->table('users')->where('uuid', 'managed-1')->value('phone'))->toBeNull();
|
|
465
|
+
|
|
466
|
+
// A staff account only takes the name
|
|
467
|
+
expect(ProfileAccountManager::syncProxyFields($staff, ['name' => 'Staff Driver', 'email' => 'changed@example.com', 'phone' => null]))->toBeTrue()
|
|
468
|
+
->and($connection->table('users')->where('uuid', 'staff-1')->first())
|
|
469
|
+
->name->toBe('Staff Driver')
|
|
470
|
+
->email->toBe('staff@example.com')
|
|
471
|
+
->phone->toBe('+6591111111');
|
|
472
|
+
|
|
473
|
+
// Taking another account's email or phone is refused
|
|
474
|
+
$emailConflict = null;
|
|
475
|
+
try {
|
|
476
|
+
ProfileAccountManager::syncProxyFields($managed, ['email' => 'Taken@example.com']);
|
|
477
|
+
} catch (ProfileIdentityConflictException $exception) {
|
|
478
|
+
$emailConflict = $exception;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
$phoneConflict = null;
|
|
482
|
+
try {
|
|
483
|
+
ProfileAccountManager::syncProxyFields($managed, ['email' => 'new@example.com', 'phone' => '+6599990000']);
|
|
484
|
+
} catch (ProfileIdentityConflictException $exception) {
|
|
485
|
+
$phoneConflict = $exception;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
expect($emailConflict?->getMessage())->toBe('This email is already in use by another account.')
|
|
489
|
+
->and($emailConflict?->getField())->toBe('email')
|
|
490
|
+
->and($phoneConflict?->getMessage())->toBe('This phone number is already in use by another account.')
|
|
491
|
+
->and($phoneConflict?->getField())->toBe('phone')
|
|
492
|
+
->and($connection->table('users')->where('uuid', 'managed-1')->value('email'))->toBe('new@example.com');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('release for profile deletes an unused managed account and leaves staff alone', function () {
|
|
496
|
+
$connection = fleetopsProfileAccountBoot();
|
|
497
|
+
config()->set('activitylog.enabled', false);
|
|
498
|
+
$lonely = fleetopsProfileAccountUser(['uuid' => 'lonely-1', 'type' => 'driver']);
|
|
499
|
+
$shared = fleetopsProfileAccountUser(['uuid' => 'shared-1', 'type' => 'contact']);
|
|
500
|
+
$staying = fleetopsProfileAccountUser(['uuid' => 'staying-1', 'type' => 'driver']);
|
|
501
|
+
$staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'type' => 'user']);
|
|
502
|
+
$connection->table('contacts')->insert(['uuid' => 'c-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1', 'type' => 'contact']);
|
|
503
|
+
$connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1']);
|
|
504
|
+
$connection->table('company_users')->insert([
|
|
505
|
+
['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => 'shared-1'],
|
|
506
|
+
['uuid' => 'cu-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1'],
|
|
507
|
+
['uuid' => 'cu-3', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1'],
|
|
508
|
+
]);
|
|
509
|
+
|
|
510
|
+
ProfileAccountManager::releaseForProfile(null, 'company-1');
|
|
511
|
+
ProfileAccountManager::releaseForProfile($staff, 'company-1');
|
|
512
|
+
ProfileAccountManager::releaseForProfile($lonely, 'company-1');
|
|
513
|
+
// Already deleted accounts are skipped
|
|
514
|
+
ProfileAccountManager::releaseForProfile($lonely, 'company-1');
|
|
515
|
+
ProfileAccountManager::releaseForProfile($shared, 'company-1');
|
|
516
|
+
ProfileAccountManager::releaseForProfile($staying, 'company-1');
|
|
517
|
+
ProfileAccountManager::releaseForProfile($staying);
|
|
518
|
+
|
|
519
|
+
expect($connection->table('users')->where('uuid', 'lonely-1')->value('deleted_at'))->not->toBeNull()
|
|
520
|
+
->and($lonely->trashed())->toBeTrue()
|
|
521
|
+
->and($connection->table('users')->where('uuid', 'staff-1')->value('deleted_at'))->toBeNull()
|
|
522
|
+
->and($connection->table('users')->where('uuid', 'shared-1')->value('deleted_at'))->toBeNull()
|
|
523
|
+
// The shared account only leaves the organization it has no profile in
|
|
524
|
+
->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'shared-1')->pluck('company_uuid')->all())->toBe(['company-2'])
|
|
525
|
+
->and($connection->table('users')->where('uuid', 'staying-1')->value('deleted_at'))->toBeNull()
|
|
526
|
+
->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'staying-1')->count())->toBe(1)
|
|
527
|
+
->and(ProfileAccountManager::profileCompanies($shared)->all())->toBe(['company-2']);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('attach to company adds membership once and only re-roles managed accounts', function () {
|
|
531
|
+
$connection = fleetopsProfileAccountBoot();
|
|
532
|
+
$managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'company_uuid' => 'company-2', 'type' => 'customer']);
|
|
533
|
+
$staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'type' => 'user']);
|
|
534
|
+
$connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']);
|
|
535
|
+
|
|
536
|
+
expect(ProfileAccountManager::attachToCompany($managed, 'company-missing', 'customer'))->toBeNull();
|
|
537
|
+
|
|
538
|
+
$first = ProfileAccountManager::attachToCompany($managed, 'company-1', 'customer');
|
|
539
|
+
$second = ProfileAccountManager::attachToCompany($managed, 'company-1', 'unknown-type');
|
|
540
|
+
$staffMembership = ProfileAccountManager::attachToCompany($staff, 'company-1', 'driver');
|
|
541
|
+
|
|
542
|
+
expect($first->uuid)->toBe($second->uuid)
|
|
543
|
+
->and($connection->table('company_users')->where('user_uuid', 'managed-1')->count())->toBe(1)
|
|
544
|
+
// An unknown type falls back to the contact role
|
|
545
|
+
->and(fleetopsProfileAccountRoles($connection, 'managed-1', 'company-1'))->toBe(['Fleet-Ops Contact'])
|
|
546
|
+
->and($staffMembership->uuid)->toBe('cu-staff')
|
|
547
|
+
->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0)
|
|
548
|
+
->and($connection->table('users')->where('uuid', 'managed-1')->value('company_uuid'))->toBe('company-2');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('send credentials resets the password activates the account and emails or texts it', function () {
|
|
552
|
+
$connection = fleetopsProfileAccountBoot();
|
|
553
|
+
$mail = Illuminate\Support\Facades\Mail::getFacadeRoot();
|
|
554
|
+
$sms = app(SmsService::class);
|
|
555
|
+
|
|
556
|
+
$emailUser = fleetopsProfileAccountUser(['uuid' => 'email-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'status' => 'pending', 'type' => 'driver']);
|
|
557
|
+
$driver = new Driver();
|
|
558
|
+
$driver->setRawAttributes(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'email-1'], true);
|
|
559
|
+
$driver->setRelation('company', (object) ['name' => 'Acme']);
|
|
560
|
+
|
|
561
|
+
expect(ProfileAccountManager::sendCredentials($driver, $emailUser, 'chosen-secret'))->toBe('email')
|
|
562
|
+
->and($connection->table('users')->where('uuid', 'email-1')->value('password'))->toBe('hashed:chosen-secret')
|
|
563
|
+
->and($connection->table('users')->where('uuid', 'email-1')->value('status'))->toBe('active')
|
|
564
|
+
->and($mail->sent[0])->toBeInstanceOf(DriverCredentialsMail::class)
|
|
565
|
+
->and($mail->to[0])->toBe($emailUser);
|
|
566
|
+
|
|
567
|
+
// An active account is not re-activated; a random password is generated
|
|
568
|
+
$customerUser = fleetopsProfileAccountUser(['uuid' => 'email-2', 'company_uuid' => 'company-1', 'email' => 'customer@example.com', 'status' => 'active', 'type' => 'customer']);
|
|
569
|
+
$customer = new Contact();
|
|
570
|
+
$customer->setRawAttributes(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'type' => 'customer'], true);
|
|
571
|
+
|
|
572
|
+
expect(ProfileAccountManager::sendCredentials($customer, $customerUser))->toBe('email')
|
|
573
|
+
->and($connection->table('users')->where('uuid', 'email-2')->value('password'))->toStartWith('hashed:')
|
|
574
|
+
->and($mail->sent[1])->toBeInstanceOf(CustomerCredentialsMail::class);
|
|
575
|
+
|
|
576
|
+
// Without an email the credentials are texted, naming the organization
|
|
577
|
+
$phoneUser = fleetopsProfileAccountUser(['uuid' => 'phone-1', 'phone' => '+6590001111', 'status' => 'active', 'type' => 'driver']);
|
|
578
|
+
expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'texted-secret'))->toBe('sms')
|
|
579
|
+
->and($sms->sent[0])->toBe(['+6590001111', 'Your Acme sign-in details. Login: +6590001111 Password: texted-secret']);
|
|
580
|
+
|
|
581
|
+
// An organization-less profile falls back to the app name
|
|
582
|
+
$orphan = new Driver();
|
|
583
|
+
$orphan->setRawAttributes(['uuid' => 'd-2'], true);
|
|
584
|
+
$orphan->setRelation('company', null);
|
|
585
|
+
ProfileAccountManager::deliverCredentials($orphan, $phoneUser, 'app-secret');
|
|
586
|
+
expect($sms->sent[1][1])->toBe('Your Fleetbase sign-in details. Login: +6590001111 Password: app-secret');
|
|
587
|
+
|
|
588
|
+
// A refused text surfaces the provider's reason
|
|
589
|
+
$sms->result = ['success' => false, 'error' => 'Invalid number'];
|
|
590
|
+
expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
|
|
591
|
+
->toThrow(Exception::class, 'The credentials could not be texted: Invalid number');
|
|
592
|
+
|
|
593
|
+
$sms->result = ['success' => false, 'message' => 'Quota exceeded'];
|
|
594
|
+
expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
|
|
595
|
+
->toThrow(Exception::class, 'The credentials could not be texted: Quota exceeded');
|
|
596
|
+
|
|
597
|
+
$sms->result = ['success' => false];
|
|
598
|
+
expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
|
|
599
|
+
->toThrow(Exception::class, 'The credentials could not be texted: the SMS provider refused it.');
|
|
600
|
+
|
|
601
|
+
// A result without a success flag is treated as sent
|
|
602
|
+
$sms->result = [];
|
|
603
|
+
expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))->toBe('sms');
|
|
604
|
+
|
|
605
|
+
$unreachable = fleetopsProfileAccountUser(['uuid' => 'none-1', 'status' => 'active', 'type' => 'driver']);
|
|
606
|
+
expect(fn () => ProfileAccountManager::deliverCredentials($driver, $unreachable, 'x'))
|
|
607
|
+
->toThrow(Exception::class, 'This profile has no email or phone to send credentials to.');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('driver credentials mail names the organization and the sign-in identity', function () {
|
|
611
|
+
config()->set('app.name', 'Fleetbase');
|
|
612
|
+
$user = new User();
|
|
613
|
+
$user->setRawAttributes(['name' => 'Dana', 'email' => null, 'phone' => '+6590001111'], true);
|
|
614
|
+
|
|
615
|
+
$driver = new Driver();
|
|
616
|
+
$driver->setRelation('company', (object) ['name' => 'Acme']);
|
|
617
|
+
|
|
618
|
+
$mail = new DriverCredentialsMail('plain-secret', $driver, $user);
|
|
619
|
+
$content = $mail->content();
|
|
620
|
+
|
|
621
|
+
expect($mail->envelope()->subject)->toBe('Your Acme driver sign-in details')
|
|
622
|
+
->and($content->markdown)->toBe('fleetops::mail.driver-credentials')
|
|
623
|
+
->and($content->with)->toMatchArray([
|
|
624
|
+
'driver' => $driver,
|
|
625
|
+
'user' => $user,
|
|
626
|
+
'companyName' => 'Acme',
|
|
627
|
+
'identity' => '+6590001111',
|
|
628
|
+
'plaintextPassword' => 'plain-secret',
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
$user->setRawAttributes(['name' => 'Dana', 'email' => 'dana@example.com'], true);
|
|
632
|
+
$orphan = new Driver();
|
|
633
|
+
$orphan->setRelation('company', null);
|
|
634
|
+
$orphanMail = new DriverCredentialsMail('plain-secret', $orphan, $user);
|
|
635
|
+
|
|
636
|
+
expect($orphanMail->envelope()->subject)->toBe('Your Fleetbase driver sign-in details')
|
|
637
|
+
->and($orphanMail->content()->with)->toMatchArray(['companyName' => 'Fleetbase', 'identity' => 'dana@example.com']);
|
|
638
|
+
|
|
639
|
+
$view = file_get_contents(__DIR__ . '/../../../resources/views/mail/driver-credentials.blade.php');
|
|
640
|
+
expect($view)->toContain('{{ $identity }}')
|
|
641
|
+
->toContain('{{ $plaintextPassword }}')
|
|
642
|
+
->toContain('{{ $companyName }}');
|
|
643
|
+
});
|