464 lines
15 KiB
JavaScript
464 lines
15 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { compileFunction } from 'node:vm';
|
|
import ts from 'typescript';
|
|
const require = createRequire(import.meta.url);
|
|
function load(file, mocks = {}, suffix = '') {
|
|
const code = ts.transpileModule(
|
|
fs.readFileSync(new URL(file, import.meta.url), 'utf8') + suffix,
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText;
|
|
const module = { exports: {} };
|
|
compileFunction(code, ['require', 'module', 'exports'])(
|
|
(name) => mocks[name] ?? require(name),
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports;
|
|
}
|
|
function harness(initial = {}, component = null) {
|
|
const cells = [],
|
|
sent = [],
|
|
reloads = [];
|
|
let cursor,
|
|
result,
|
|
props = {
|
|
id: 'one',
|
|
kind: 'investor',
|
|
version: 1,
|
|
values: { name: 'original', status: 'prospect' },
|
|
...initial,
|
|
};
|
|
const form = {
|
|
errors: {},
|
|
processing: false,
|
|
setData(key, value) {
|
|
this.data =
|
|
typeof key === 'function'
|
|
? key(this.data)
|
|
: typeof key === 'object'
|
|
? key
|
|
: { ...this.data, [key]: value };
|
|
},
|
|
setDefaults(value) {
|
|
this.defaults = value;
|
|
},
|
|
clearErrors(key) {
|
|
if (key) delete this.errors[key];
|
|
else this.errors = {};
|
|
},
|
|
transform(callback) {
|
|
this.transformer = callback;
|
|
},
|
|
patch(url, options) {
|
|
sent.push({
|
|
url,
|
|
options,
|
|
data: structuredClone(this.transformer(this.data)),
|
|
});
|
|
},
|
|
};
|
|
const mocks = {
|
|
react: {
|
|
useRef(initial) {
|
|
const i = cursor++;
|
|
cells[i] ??= { current: initial };
|
|
return cells[i];
|
|
},
|
|
useState(initial) {
|
|
const i = cursor++;
|
|
if (!(i in cells)) cells[i] = initial;
|
|
return [
|
|
cells[i],
|
|
(value) => {
|
|
cells[i] =
|
|
typeof value === 'function'
|
|
? value(cells[i])
|
|
: value;
|
|
},
|
|
];
|
|
},
|
|
},
|
|
'@inertiajs/react': {
|
|
Link: 'a',
|
|
router: {
|
|
reload(options) {
|
|
reloads.push(options);
|
|
},
|
|
},
|
|
useForm(initial) {
|
|
form.data ??= structuredClone(initial);
|
|
return form;
|
|
},
|
|
},
|
|
'../management/shared': {
|
|
Management: 'main',
|
|
Field: 'label',
|
|
control: '',
|
|
button: '',
|
|
},
|
|
'./types': {
|
|
leadStates: { new: 'Novo', qualified: 'Qualificado' },
|
|
taskStates: { open: 'Aberta', in_progress: 'Em curso' },
|
|
},
|
|
};
|
|
const hook = load('../../resources/js/lib/crm-record-edit.ts', mocks);
|
|
mocks['@/lib/crm-record-edit'] = hook;
|
|
mocks['@/components/crm-edit-comparison'] = { default: () => null };
|
|
mocks['@/components/crm-task-creation'] = { default: () => null };
|
|
const renderComponent = component
|
|
? load(component.file, mocks, component.suffix ?? '')[
|
|
component.name ?? 'default'
|
|
]
|
|
: hook.useCrmRecordEdit;
|
|
function render(patch = {}) {
|
|
props = { ...props, ...patch };
|
|
cursor = 0;
|
|
result = renderComponent(props);
|
|
return result;
|
|
}
|
|
render();
|
|
return {
|
|
form,
|
|
sent,
|
|
reloads,
|
|
render,
|
|
get edit() {
|
|
return result;
|
|
},
|
|
};
|
|
}
|
|
function nodes(tree) {
|
|
return Array.isArray(tree)
|
|
? tree.flatMap(nodes)
|
|
: tree && typeof tree === 'object'
|
|
? [tree, ...nodes(tree.props?.children)]
|
|
: [];
|
|
}
|
|
const text = (tree) =>
|
|
typeof tree === 'string'
|
|
? tree
|
|
: Array.isArray(tree)
|
|
? tree.map(text).join('')
|
|
: tree?.props
|
|
? text(tree.props.children)
|
|
: '';
|
|
|
|
test('different fields merge only after explicit adoption, retaining batched deliberate edits', () => {
|
|
const h = harness();
|
|
h.edit.change('name', 'my name');
|
|
h.edit.change('extra', 'ignored');
|
|
h.render({ version: 2, values: { name: 'original', status: 'active' } });
|
|
assert.deepEqual(h.edit.changed, ['status']);
|
|
assert.deepEqual(h.edit.unresolved, []);
|
|
h.edit.submit('/synthetic');
|
|
assert.equal(h.sent.length, 0);
|
|
h.edit.adopt();
|
|
h.render();
|
|
assert.deepEqual(h.edit.values, { name: 'my name', status: 'active' });
|
|
h.edit.submit('/synthetic');
|
|
assert.deepEqual(h.sent[0].data, {
|
|
name: 'my name',
|
|
status: 'active',
|
|
version: 2,
|
|
});
|
|
});
|
|
|
|
test('same-field conflicts require a choice and deliberate return to the initial value is retained', () => {
|
|
const h = harness();
|
|
h.edit.change('name', 'temporary');
|
|
h.edit.change('name', 'original');
|
|
h.render({
|
|
version: 2,
|
|
values: { name: 'other operator', status: 'prospect' },
|
|
});
|
|
assert.deepEqual(h.edit.unresolved, ['name']);
|
|
h.edit.adopt();
|
|
h.render();
|
|
assert.equal(h.edit.baseVersion, 1);
|
|
h.edit.choose('name', 'mine');
|
|
h.render();
|
|
h.edit.adopt();
|
|
h.render();
|
|
assert.equal(h.edit.values.name, 'original');
|
|
assert.equal(h.edit.baseVersion, 2);
|
|
});
|
|
|
|
test('choosing the current value changes only that field and another update requires a fresh choice', () => {
|
|
const h = harness();
|
|
h.edit.change('name', 'mine');
|
|
h.edit.change('status', 'inactive');
|
|
h.render({ version: 2, values: { name: 'remote', status: 'prospect' } });
|
|
h.edit.choose('name', 'current');
|
|
h.render();
|
|
h.edit.adopt();
|
|
h.render();
|
|
assert.deepEqual(h.edit.values, { name: 'remote', status: 'inactive' });
|
|
h.render({
|
|
version: 3,
|
|
values: { name: 'new remote', status: 'prospect' },
|
|
});
|
|
assert.deepEqual(h.edit.unresolved, ['name']);
|
|
});
|
|
|
|
test('refresh keeps the draft and version error until the current revision is compared', () => {
|
|
const h = harness();
|
|
h.edit.change('name', 'draft');
|
|
h.form.errors.version = 'Stale';
|
|
h.render();
|
|
h.edit.submit('/synthetic');
|
|
assert.equal(h.sent.length, 0);
|
|
h.edit.refresh();
|
|
h.render();
|
|
assert.deepEqual(h.reloads[0].only, ['investor', 'contacts']);
|
|
assert.equal(h.edit.values.name, 'draft');
|
|
assert.equal(h.edit.blocked, true);
|
|
h.reloads[0].onFinish();
|
|
h.render({ version: 2, values: { name: 'original', status: 'active' } });
|
|
h.edit.adopt();
|
|
h.render();
|
|
assert.equal(h.form.errors.version, undefined);
|
|
assert.equal(h.edit.values.name, 'draft');
|
|
});
|
|
|
|
test('success uses the actual returned record and keeps edits made while saving', () => {
|
|
const h = harness({
|
|
kind: 'contact',
|
|
values: { email: 'UPPER@EXAMPLE.INVALID', phone: '111' },
|
|
});
|
|
h.edit.submit('/synthetic');
|
|
h.edit.change('email', 'temporary');
|
|
h.edit.change('email', 'UPPER@EXAMPLE.INVALID');
|
|
h.edit.change('phone', '222');
|
|
assert.equal(h.sent[0].data.phone, '111');
|
|
h.sent[0].options.onSuccess({
|
|
props: {
|
|
contacts: [
|
|
{ id: 'different', version_no: 900 },
|
|
{
|
|
id: 'one',
|
|
version_no: 2,
|
|
email: 'upper@example.invalid',
|
|
phone: '111',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
h.render({
|
|
version: 2,
|
|
values: { email: 'upper@example.invalid', phone: '111' },
|
|
});
|
|
assert.deepEqual(h.edit.values, {
|
|
email: 'UPPER@EXAMPLE.INVALID',
|
|
phone: '222',
|
|
});
|
|
assert.deepEqual(h.form.defaults, {
|
|
version: 2,
|
|
values: { email: 'upper@example.invalid', phone: '111' },
|
|
});
|
|
h.edit.submit('/synthetic');
|
|
assert.equal(h.sent[1].data.version, 2);
|
|
});
|
|
|
|
test('lead success clears the previous event reason while keeping a newly typed reason', () => {
|
|
for (const duringSave of [false, true]) {
|
|
const h = harness({
|
|
kind: 'lead',
|
|
values: { status: 'new', qualification_reason: '' },
|
|
blankAfterSave: ['qualification_reason'],
|
|
});
|
|
h.edit.change('qualification_reason', 'first event');
|
|
h.render();
|
|
h.edit.submit('/synthetic');
|
|
if (duringSave) h.edit.change('qualification_reason', 'new event');
|
|
h.sent[0].options.onSuccess({
|
|
props: {
|
|
taskCreation: { lead_id: 'one', actor_id: 1, commands: [] },
|
|
lead: {
|
|
id: 'one',
|
|
version_no: 2,
|
|
status: 'qualified',
|
|
qualification_reason: 'first event',
|
|
},
|
|
},
|
|
});
|
|
h.render({
|
|
version: 2,
|
|
values: { status: 'qualified', qualification_reason: '' },
|
|
});
|
|
assert.equal(
|
|
h.edit.values.qualification_reason,
|
|
duringSave ? 'new event' : '',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('task success resolves the exact task; invalid responses and another record cannot reset a draft', () => {
|
|
const h = harness({ kind: 'task', values: { status: 'open' } });
|
|
h.edit.change('status', 'in_progress');
|
|
h.render();
|
|
h.edit.submit('/synthetic');
|
|
for (const props of [
|
|
{ lead: { tasks: [{ id: 'other', version_no: 2, status: 'done' }] } },
|
|
{ lead: { tasks: [{ id: 'one', version_no: '2', status: 'done' }] } },
|
|
]) {
|
|
h.sent[0].options.onSuccess({ props });
|
|
assert.equal(h.form.data.values.status, 'in_progress');
|
|
}
|
|
h.sent[0].options.onSuccess({
|
|
props: {
|
|
lead: {
|
|
tasks: [{ id: 'one', version_no: 2, status: 'in_progress' }],
|
|
},
|
|
},
|
|
});
|
|
h.render({ version: 2 });
|
|
assert.equal(h.edit.baseVersion, 2);
|
|
h.edit.submit('/synthetic');
|
|
h.render({ id: 'two' });
|
|
h.sent[1].options.onSuccess({
|
|
props: {
|
|
lead: { tasks: [{ id: 'one', version_no: 3, status: 'done' }] },
|
|
},
|
|
});
|
|
assert.equal(h.form.data.values.status, 'in_progress');
|
|
assert.equal(h.edit.blocked, true);
|
|
});
|
|
|
|
test('invalid version blocks a command and transformed field errors remain accessible', () => {
|
|
const h = harness({ version: 0 });
|
|
h.edit.submit('/synthetic');
|
|
assert.equal(h.sent.length, 0);
|
|
h.form.errors.email = 'Invalid email';
|
|
assert.equal(h.edit.error('email'), 'Invalid email');
|
|
});
|
|
|
|
test('comparison offers explicit non-submit choices and blocks adoption while conflicts remain', () => {
|
|
const h = harness();
|
|
h.edit.change('name', 'mine');
|
|
h.render({ version: 2, values: { name: 'remote', status: 'prospect' } });
|
|
const Panel = load(
|
|
'../../resources/js/components/crm-edit-comparison.tsx',
|
|
).default;
|
|
const tree = Panel({ editor: h.edit, labels: { name: 'Nome' } });
|
|
const buttons = nodes(tree).filter((node) => node.type === 'button');
|
|
assert.ok(buttons.every((button) => button.props.type === 'button'));
|
|
assert.match(text(tree), /Actualmente guardado/);
|
|
assert.match(text(tree), /mine/);
|
|
assert.match(text(tree), /remote/);
|
|
assert.equal(buttons.at(-1).props.disabled, true);
|
|
buttons
|
|
.find((button) => text(button).includes('Manter minha'))
|
|
.props.onClick();
|
|
h.render();
|
|
assert.equal(h.edit.unresolved.length, 0);
|
|
});
|
|
|
|
test('all four CRM forms send their real revisions without asking the user to enter one', () => {
|
|
const cases = [
|
|
{
|
|
file: '../../resources/js/pages/crm/investor.tsx',
|
|
props: {
|
|
investor: {
|
|
id: 'one',
|
|
version_no: 6,
|
|
legal_name: 'Synthetic investor',
|
|
investor_type: 'domestic',
|
|
status: 'prospect',
|
|
},
|
|
contacts: [],
|
|
leads: [],
|
|
},
|
|
expected: {
|
|
legal_name: 'Synthetic investor',
|
|
investor_type: 'domestic',
|
|
status: 'prospect',
|
|
version: 6,
|
|
},
|
|
},
|
|
{
|
|
file: '../../resources/js/pages/crm/investor.tsx',
|
|
suffix: '\nexport { ContactForm };',
|
|
name: 'ContactForm',
|
|
props: {
|
|
investorId: 'i',
|
|
contact: {
|
|
id: 'one',
|
|
version_no: 7,
|
|
full_name: 'Synthetic',
|
|
email: 'a@example.invalid',
|
|
phone: '',
|
|
},
|
|
},
|
|
expected: {
|
|
full_name: 'Synthetic',
|
|
email: 'a@example.invalid',
|
|
phone: '',
|
|
version: 7,
|
|
},
|
|
},
|
|
{
|
|
file: '../../resources/js/pages/crm/lead.tsx',
|
|
props: {
|
|
taskCreation: { lead_id: 'one', actor_id: 1, commands: [] },
|
|
lead: {
|
|
id: 'one',
|
|
version_no: 9,
|
|
title: 'Synthetic lead',
|
|
status: 'new',
|
|
owner_user_id: 1,
|
|
tasks: [],
|
|
},
|
|
contacts: [],
|
|
owners: [{ id: 1, name: 'Synthetic owner' }],
|
|
},
|
|
expected: {
|
|
status: 'new',
|
|
owner_user_id: '1',
|
|
qualification_reason: '',
|
|
version: 9,
|
|
},
|
|
},
|
|
{
|
|
file: '../../resources/js/pages/crm/lead.tsx',
|
|
suffix: '\nexport { TaskRow };',
|
|
name: 'TaskRow',
|
|
props: {
|
|
leadId: 'l',
|
|
task: {
|
|
id: 'one',
|
|
version_no: 8,
|
|
title: 'Synthetic',
|
|
status: 'open',
|
|
due_at: null,
|
|
assignee_user_id: 1,
|
|
can_update: true,
|
|
},
|
|
},
|
|
expected: { status: 'open', version: 8 },
|
|
},
|
|
];
|
|
for (const item of cases) {
|
|
const h = harness(item.props, item);
|
|
nodes(h.edit)
|
|
.find((node) => node.type === 'form')
|
|
.props.onSubmit({ preventDefault() {} });
|
|
assert.deepEqual(h.sent[0].data, item.expected);
|
|
assert.equal(
|
|
nodes(h.edit).filter(
|
|
(node) =>
|
|
node.type === 'input' && node.props.name === 'version',
|
|
).length,
|
|
0,
|
|
);
|
|
}
|
|
});
|