- Introduced `selfserve_task_attachment_payloads` class for managing task attachments, including formatting and download URL generation. - Added unit and API tests to validate attachment handling in self-serve tasks and customer-scoped workflows. - Enhanced wash start simulation and studio graph projections to integrate task attachment data.
379 lines
13 KiB
PHP
379 lines
13 KiB
PHP
<?php
|
|
|
|
app_require('modules/selfserve/classes/selfserve_config_versioning.php');
|
|
app_require('objects/selfserve_config_versions_o.php');
|
|
|
|
use modules\selfserve\classes\selfserve_config_versioning;
|
|
use objects\selfserve_config_versions_o;
|
|
|
|
function selfserve_config_versioning_without_constructor(): selfserve_config_versioning
|
|
{
|
|
$reflection = new ReflectionClass(selfserve_config_versioning::class);
|
|
return $reflection->newInstanceWithoutConstructor();
|
|
}
|
|
|
|
it('validates typed task gates for known references', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$validation = $service->validateConfig([
|
|
'questions' => [
|
|
['id' => 10],
|
|
],
|
|
'conditions' => [
|
|
['id' => 20],
|
|
],
|
|
'rules' => [],
|
|
'tasks' => [
|
|
[
|
|
'id' => 100,
|
|
'gate_type' => 'ALWAYS',
|
|
'gate_ref_id' => null,
|
|
],
|
|
[
|
|
'id' => 101,
|
|
'gate_type' => 'CONDITION',
|
|
'gate_ref_id' => 20,
|
|
],
|
|
[
|
|
'id' => 102,
|
|
'gate_type' => 'QUESTION',
|
|
'gate_ref_id' => 10,
|
|
],
|
|
],
|
|
]);
|
|
|
|
expect($validation['valid'])->toBeTrue();
|
|
expect($validation['errors'])->toBe([]);
|
|
});
|
|
|
|
it('fails validation when typed task gates reference unknown entities', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$validation = $service->validateConfig([
|
|
'questions' => [
|
|
['id' => 1],
|
|
],
|
|
'conditions' => [
|
|
['id' => 2],
|
|
],
|
|
'rules' => [],
|
|
'tasks' => [
|
|
[
|
|
'id' => 200,
|
|
'gate_type' => 'CONDITION',
|
|
'gate_ref_id' => 999,
|
|
],
|
|
[
|
|
'id' => 201,
|
|
'gate_type' => 'QUESTION',
|
|
'gate_ref_id' => 998,
|
|
],
|
|
[
|
|
'id' => 202,
|
|
'gate_type' => 'QUESTION',
|
|
'gate_ref_id' => null,
|
|
],
|
|
],
|
|
]);
|
|
|
|
expect($validation['valid'])->toBeFalse();
|
|
expect(implode("\n", $validation['errors']))->toContain('unknown condition gate_ref_id 999');
|
|
expect(implode("\n", $validation['errors']))->toContain('unknown question gate_ref_id 998');
|
|
expect(implode("\n", $validation['errors']))->toContain('requires gate_ref_id');
|
|
});
|
|
|
|
it('fails validation when nested conditions form cycles', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$validation = $service->validateConfig([
|
|
'questions' => [],
|
|
'conditions' => [
|
|
['id' => 10, 'condition_id' => 12],
|
|
['id' => 11, 'condition_id' => 10],
|
|
['id' => 12, 'condition_id' => 11],
|
|
],
|
|
'rules' => [],
|
|
'tasks' => [],
|
|
]);
|
|
|
|
expect($validation['valid'])->toBeFalse();
|
|
expect(implode("\n", $validation['errors']))->toContain('Condition cycle detected');
|
|
});
|
|
|
|
it('migrates legacy AND and OR rules into grouped v2 condition expressions', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$config = $service->migrateLegacyConfigToV2([
|
|
'department_id' => 6,
|
|
'questions' => [
|
|
['id' => 1],
|
|
['id' => 2],
|
|
['id' => 3],
|
|
],
|
|
'conditions' => [
|
|
['id' => 10, 'name' => 'Ready'],
|
|
],
|
|
'rules' => [
|
|
['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1],
|
|
['id' => 101, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 2],
|
|
['id' => 102, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 3],
|
|
],
|
|
'tasks' => [],
|
|
]);
|
|
|
|
$expression = $config['conditions'][0]['expression'];
|
|
|
|
expect($config['schema_version'])->toBe(2);
|
|
expect($config['rules'])->toBe([]);
|
|
expect($expression['operator'])->toBe('ALL');
|
|
expect($expression['children'][0])->toMatchArray([
|
|
'type' => 'predicate',
|
|
'subject_type' => 'question',
|
|
'subject_id' => 1,
|
|
'operator' => 'IS_TRUE',
|
|
]);
|
|
expect($expression['children'][1]['operator'])->toBe('ANY');
|
|
expect(array_column($expression['children'][1]['children'], 'subject_id'))->toBe([2, 3]);
|
|
});
|
|
|
|
it('repairs legacy-defaulted always task gates during v2 normalization', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$config = $service->migrateLegacyConfigToV2([
|
|
'department_id' => 6,
|
|
'questions' => [
|
|
['id' => 1],
|
|
],
|
|
'conditions' => [
|
|
['id' => 20, 'name' => 'Machine wash allowed'],
|
|
],
|
|
'rules' => [
|
|
['id' => 100, 'condition_id' => 20, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1],
|
|
],
|
|
'tasks' => [
|
|
[
|
|
'id' => 200,
|
|
'condition_id' => 20,
|
|
'gate_type' => 'ALWAYS',
|
|
'gate_ref_id' => null,
|
|
],
|
|
],
|
|
]);
|
|
|
|
expect($config['tasks'][0]['gate_type'])->toBe('CONDITION');
|
|
expect($config['tasks'][0]['gate_ref_id'])->toBe(20);
|
|
expect($service->validateConfig($config)['valid'])->toBeTrue();
|
|
});
|
|
|
|
it('does not let legacy-defaulted always task gates bypass validation', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$validation = $service->validateConfig([
|
|
'schema_version' => 2,
|
|
'questions' => [
|
|
['id' => 1],
|
|
],
|
|
'conditions' => [
|
|
[
|
|
'id' => 20,
|
|
'expression' => [
|
|
'type' => 'predicate',
|
|
'subject_type' => 'question',
|
|
'subject_id' => 1,
|
|
'operator' => 'IS_TRUE',
|
|
],
|
|
],
|
|
],
|
|
'tasks' => [
|
|
[
|
|
'id' => 201,
|
|
'condition_id' => 999,
|
|
'gate_type' => 'ALWAYS',
|
|
'gate_ref_id' => null,
|
|
],
|
|
],
|
|
]);
|
|
|
|
expect($validation['valid'])->toBeFalse();
|
|
expect(implode("\n", $validation['errors']))->toContain('unknown question gate_ref_id 999');
|
|
});
|
|
|
|
it('rejects unsupported legacy task-target rules after migration', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$config = $service->migrateLegacyConfigToV2([
|
|
'department_id' => 6,
|
|
'questions' => [['id' => 1]],
|
|
'conditions' => [['id' => 10, 'name' => 'Ready']],
|
|
'rules' => [
|
|
['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'task', 'object_id' => 50],
|
|
],
|
|
'tasks' => [['id' => 50]],
|
|
]);
|
|
$validation = $service->validateConfig($config);
|
|
|
|
expect($validation['valid'])->toBeFalse();
|
|
expect(implode("\n", $validation['errors']))->toContain('unsupported object_type `task`');
|
|
});
|
|
|
|
it('validates v2 expressions for empty used conditions, missing refs, invalid operators, and cycles', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$validation = $service->validateConfig([
|
|
'schema_version' => 2,
|
|
'questions' => [
|
|
['id' => 1, 'condition_id' => 10],
|
|
],
|
|
'conditions' => [
|
|
['id' => 10, 'expression' => ['type' => 'group', 'operator' => 'ALL', 'children' => []]],
|
|
[
|
|
'id' => 20,
|
|
'expression' => [
|
|
'type' => 'group',
|
|
'operator' => 'ALL',
|
|
'children' => [
|
|
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 999, 'operator' => 'IS_TRUE'],
|
|
['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 30, 'operator' => 'IS_TRUE'],
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'id' => 30,
|
|
'expression' => [
|
|
'type' => 'group',
|
|
'operator' => 'ALL',
|
|
'children' => [
|
|
['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'NOPE'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'tasks' => [
|
|
['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 10],
|
|
],
|
|
]);
|
|
|
|
$errors = implode("\n", $validation['errors']);
|
|
|
|
expect($validation['valid'])->toBeFalse();
|
|
expect($errors)->toContain('Condition 10 is used but has an empty expression.');
|
|
expect($errors)->toContain('unknown question predicate subject_id 999');
|
|
expect($errors)->toContain('invalid predicate operator `NOPE`');
|
|
expect($errors)->toContain('Condition cycle detected');
|
|
});
|
|
|
|
it('validates nested v2 branch and case expressions', function (): void {
|
|
$service = selfserve_config_versioning_without_constructor();
|
|
|
|
$validation = $service->validateConfig([
|
|
'schema_version' => 2,
|
|
'questions' => [
|
|
['id' => 1],
|
|
['id' => 2],
|
|
['id' => 3],
|
|
],
|
|
'conditions' => [
|
|
[
|
|
'id' => 10,
|
|
'expression' => [
|
|
'type' => 'branch',
|
|
'branches' => [
|
|
[
|
|
'kind' => 'if',
|
|
'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'],
|
|
'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'],
|
|
],
|
|
[
|
|
'kind' => 'else',
|
|
'else' => true,
|
|
'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE_OR_NOT_SET'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'id' => 20,
|
|
'expression' => [
|
|
'type' => 'case',
|
|
'subject_type' => 'condition',
|
|
'subject_id' => 10,
|
|
'cases' => [
|
|
[
|
|
'value' => true,
|
|
'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'],
|
|
],
|
|
[
|
|
'value' => false,
|
|
'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'tasks' => [
|
|
['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 20],
|
|
],
|
|
]);
|
|
|
|
expect($validation['valid'])->toBeTrue();
|
|
expect($validation['errors'])->toBe([]);
|
|
});
|
|
|
|
it('encodes config json payloads with apostrophes before persistence', function (): void {
|
|
$version = new class extends selfserve_config_versions_o {
|
|
/** @var array<string,mixed> */
|
|
public array $capturedPayload = [];
|
|
|
|
public function __construct()
|
|
{
|
|
// Skip db bootstrap for this unit test.
|
|
}
|
|
|
|
public function add_object(array $data): int
|
|
{
|
|
$this->capturedPayload = $data;
|
|
return 123;
|
|
}
|
|
|
|
public function getObjectProperties(): void
|
|
{
|
|
// No-op for this unit test.
|
|
}
|
|
|
|
public function objectChanged(): void
|
|
{
|
|
// No-op for this unit test.
|
|
}
|
|
};
|
|
|
|
$version->add(
|
|
42,
|
|
selfserve_config_versioning::STATUS_DRAFT,
|
|
1,
|
|
[
|
|
'questions' => [
|
|
[
|
|
'id' => 1,
|
|
'question' => "Driver's side check",
|
|
],
|
|
],
|
|
'conditions' => [],
|
|
'rules' => [],
|
|
'tasks' => [],
|
|
],
|
|
[
|
|
'valid' => true,
|
|
'errors' => [],
|
|
'warnings' => [],
|
|
],
|
|
);
|
|
|
|
$configJson = $version->capturedPayload['config_json'] ?? null;
|
|
$validationJson = $version->capturedPayload['validation_result_json'] ?? null;
|
|
|
|
expect($configJson)->toBeString();
|
|
expect($validationJson)->toBeString();
|
|
expect(json_decode($configJson, true)['questions'][0]['question'] ?? null)->toBe("Driver's side check");
|
|
expect(json_decode($validationJson, true)['valid'] ?? null)->toBeTrue();
|
|
});
|