Files
2026-10-08 20:23:15 +03:30

132 lines
12 KiB
JavaScript

const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
const ts = require('typescript'), axios = require('axios');
function harness() {
const cache = {}, state = [], queries = new Map(), requests = [], invalidations = [], downloads = [];
let cursor = 0;
const local = new Map([['access', 'token']]);
const window = { localStorage: { getItem: k => local.get(k) ?? null, setItem: (k, v) => local.set(k,v), removeItem: k => local.delete(k) }, sessionStorage: { removeItem() {} }, location: { pathname: '/products', replace() {} }, confirm: () => true };
const document = { cookie: '', body: { appendChild() {} }, createElement: () => ({ click() { downloads.push(this.download); }, remove() {} }) };
class FormDataStub { get [Symbol.toStringTag]() { return 'FormData'; } [Symbol.iterator]() { return this.values.entries(); } constructor(values = {}) { this.values = new Map(Object.entries(values)); } get(k) { return this.values.get(k) ?? null; } set(k,v) { this.values.set(k,v); } append(k,v) { this.values.set(k,v); } entries() { return this.values.entries(); } }
const react = { ...require('react'), useState(initial) { const i = cursor++; if (!(i in state)) state[i] = initial; return [state[i], v => state[i] = typeof v === 'function' ? v(state[i]) : v]; } };
const query = { useQuery: o => queries.get(o.queryKey[0]) || { isLoading: false, isError: false, refetch() {} }, useQueryClient: () => ({ invalidateQueries: async o => invalidations.push(o.queryKey[0]) }) };
function load(filename) {
const full = path.resolve(filename); if (cache[full]) return cache[full].exports;
const module = { exports: {} }; cache[full] = module;
const source = ts.transpileModule(fs.readFileSync(full, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true, jsx: ts.JsxEmit.ReactJSX } }).outputText;
const resolve = name => {
if (name === 'axios') return axios;
if (name === 'react') return react;
if (name === '@tanstack/react-query') return query;
if (name === 'react/jsx-runtime' || name.startsWith('react-icons/')) return require(name);
if (name === 'next/link') return { __esModule: true, default: 'a' };
const base = name.startsWith('@/') ? path.resolve('src', name.slice(2)) : path.resolve(path.dirname(full), name);
return load(base + (fs.existsSync(base+'.ts') ? '.ts' : '.tsx'));
};
vm.runInNewContext('(function(require,module,exports){'+source+'\n})', { window, document, process, console, Error, BigInt, Set, Map, Blob, URL: { createObjectURL: () => 'blob:test', revokeObjectURL() {} }, FormData: FormDataStub, setTimeout: cb => cb() })(resolve, module, module.exports);
return module.exports;
}
const api = load('src/axios/axiosInstance.ts').default;
api.defaults.adapter = async config => { requests.push(config); return { data: {}, status: 200, headers: {}, config }; };
return { catalog: load('src/lib/catalog.ts'), api, load, requests, queries, state, invalidations, downloads, render: (component, props) => { cursor = 0; return component(props); } };
}
function all(tree) { if (tree == null || typeof tree === 'boolean') return []; if (Array.isArray(tree)) return tree.flatMap(all); if (typeof tree !== 'object') return [tree]; return [tree, ...all(tree.props?.children)]; }
function text(tree) { return all(tree).filter(v => typeof v === 'string' || typeof v === 'number').join(' '); }
function find(tree, condition) { const value = all(tree).find(v => v && typeof v === 'object' && condition(v)); assert.ok(value, 'Expected UI element'); return value; }
const root = 'src/app/(index)/products/';
const category = { id: 1, name: 'C', parent: null, order_cat: null, excel_order: null, image: null };
const product = { id: 1, name: 'P', price: '100', category: 1, category_name: 'C', acc_code: 'P1', attr: null, view_only: false, order_prd: null, excel_order: null };
test('server capability and layout fail closed for missing permission, nonadmin and request failure', () => {
const h = harness(), Layout = h.load(root+'layout.tsx').default;
for (const info of [null, {}, { role: 'workspace_admin' }, { permissions: { manage_products: 'true' } }, { permissions: { manage_products: false } }]) {
assert.equal(h.catalog.canManageProducts(info), false);
h.queries.set('cuser-info', { data: { data: info } });
assert.doesNotMatch(text(h.render(Layout, { children: 'PRIVATE_EDITOR' })), /PRIVATE_EDITOR/);
}
h.queries.set('cuser-info', { isError: true, error: new Error('Denied'), refetch() {} });
assert.doesNotMatch(text(h.render(Layout, { children: 'PRIVATE_EDITOR' })), /PRIVATE_EDITOR/);
h.queries.set('cuser-info', { data: { data: { permissions: { manage_products: true } } } });
assert.match(text(h.render(Layout, { children: 'PRIVATE_EDITOR' })), /PRIVATE_EDITOR/);
});
test('numeric inputs preserve exact bigint price and accept Persian Arabic digits', () => {
const h = harness();
assert.equal(h.catalog.integerInput('۹٬۲۲۳', 'قیمت'), '9223');
assert.equal(h.catalog.integerInput('١٢٣', 'ترتیب'), '123');
assert.equal(h.catalog.integerInput('9223372036854775807', 'قیمت', false, '9223372036854775807'), '9223372036854775807');
assert.equal(h.catalog.integerInput('', 'ترتیب', true), null);
for (const value of ['-1','1.5','NaN','2147483648']) assert.throws(() => h.catalog.integerInput(value, 'ترتیب'));
});
test('category path renders ancestors and stops invalid cycles', () => {
const h = harness(), parent = { ...category, name: 'Parent' }, child = { ...category, id: 2, parent: 1, name: 'Child' };
assert.equal(h.catalog.categoryLabel(child, [parent, child]), 'Parent / Child');
parent.parent = 2; assert.ok(h.catalog.categoryLabel(child, [parent, child]).length < 100);
});
test('API client uses protected routes, authenticated requests and exact bulk payload', async () => {
const h = harness();
await h.catalog.listProducts({ search: 'P', page: 2 });
await h.catalog.saveProduct({ name: 'P', price: '1', category: 1 }, 7);
await h.catalog.deleteProduct(8); await h.catalog.bulkProducts([7,8], 'move', { category: 2 });
assert.equal(h.requests[0].url, 'shop/manage/products/'); assert.equal(h.requests[0].params.search, 'P');
assert.equal(h.requests[1].method, 'patch'); assert.equal(h.requests[2].method, 'delete');
assert.deepEqual(JSON.parse(h.requests[3].data).ids, [7,8]); assert.equal(h.requests[0].headers.Authorization, 'Bearer token');
});
test('product form displays duplicate code errors and allows retry', async () => {
const h = harness(), Form = h.load(root+'components/product-form.tsx').default;
h.api.defaults.adapter = async config => { throw new axios.AxiosError('Duplicate', 'ERR_BAD_REQUEST', config, null, { data: { acc_code: ['کد محصول تکراری است.'] }, status: 400, headers: {}, config }); };
const props = { categories: [category], onSaved() { throw new Error('Should not succeed'); }, onCancel() {} };
await h.render(Form, props).props.onSubmit({ preventDefault() {}, currentTarget: { name: 'P', price: '10', category: '1', acc_code: 'P1', attr: '', order_prd: '', excel_order: '' } });
const tree = h.render(Form, props);
assert.match(text(tree), /کد محصول تکراری/); assert.equal(find(tree, v => v.type === 'button' && text(v).includes('ذخیره')).props.disabled, false);
});
test('product form validates attributes and submits large price as string', async () => {
const h = harness(), Form = h.load(root+'components/product-form.tsx').default;
let saved = 0; const props = { categories: [category], onSaved: () => saved++, onCancel() {} };
const data = { name: 'P', price: '9223372036854775807', category: '1', acc_code: 'P1', attr: '[]', order_prd: '', excel_order: '' };
await h.render(Form, props).props.onSubmit({ preventDefault() {}, currentTarget: data });
assert.equal(h.requests.length, 0); assert.match(text(h.render(Form, props)), /شیء JSON/);
data.attr = '{}'; await h.render(Form, props).props.onSubmit({ preventDefault() {}, currentTarget: data });
assert.equal(JSON.parse(h.requests[0].data).price, '9223372036854775807'); assert.equal(saved, 1);
});
test('products page applies filters and exposes editing controls', () => {
const h = harness(), Page = h.load(root+'page.tsx').default;
h.queries.set('managed-products', { data: { count: 1, results: [product], next: null } }); h.queries.set('managed-categories', { data: [category] });
const tree = h.render(Page);
find(tree, v => v.type === 'form').props.onSubmit({ preventDefault() {}, currentTarget: { search: 'P1', ordering: 'price', category: '1', view_only: '' } });
assert.equal(h.state[0].search, 'P1'); assert.equal(h.state[1], 1);
assert.match(text(tree), /افزودن محصول/); assert.match(text(tree), /ویرایش/); assert.match(text(tree), /حذف/);
});
test('category form sends parent and independently nullable ordering', async () => {
const h = harness(), Page = h.load(root+'categories/page.tsx').default;
h.queries.set('managed-categories', { data: [category] });
let tree = h.render(Page); find(tree, v => v.type === 'button' && text(v).includes('افزودن دسته')).props.onClick();
tree = h.render(Page);
await find(tree, v => v.type === 'form').props.onSubmit({ preventDefault() {}, currentTarget: { name: 'Child', parent: '1', order_cat: '', excel_order: '۴', image: null } });
const body = JSON.parse(h.requests[0].data); assert.equal(body.parent, 1); assert.equal(body.order_cat, null); assert.equal(body.excel_order, 4);
assert.ok(h.invalidations.includes('managed-categories'));
});
test('Excel page displays per-row errors and blocks atomic confirmation', async () => {
const h = harness(), Page = h.load(root+'import/page.tsx').default;
const batch = { id: 3, filename: 'file.xlsx', created: 1, updated: 0, errors: 1, committed: false, can_confirm: false, expires_at: '2099-01-01', row_count: 2, row_page: 1, options: { errors: 'atomic' }, rows: [{ row: 3, values: { name: 'Bad', acc_code: 'BAD', category: 'C' }, status: 'error', errors: { price: ['قیمت نامعتبر است.'] } }] };
h.api.defaults.adapter = async config => { h.requests.push(config); return { data: batch, status: 201, headers: {}, config }; };
let tree = h.render(Page);
await find(tree, v => v.type === 'form').props.onSubmit({ preventDefault() {}, currentTarget: { file: 'xlsx', mode: 'upsert', errors: 'atomic', create_categories: 'on', mapping: '', ordering: 'preserve' } });
tree = h.render(Page); assert.match(text(tree), /قیمت نامعتبر/);
assert.equal(find(tree, v => v.type === 'button' && text(v).includes('تأیید و ثبت نهایی')).props.disabled, true);
assert.equal(h.requests[0].url, 'shop/manage/import/preview/'); assert.equal(h.requests[0].timeout, 120000);
assert.ok(h.invalidations.includes('product-imports'));
});
test('download errors retain API message and do not download a false success file', async () => {
const h = harness();
h.api.defaults.adapter = async config => { throw new axios.AxiosError('Forbidden', 'ERR_BAD_RESPONSE', config, null, { status: 403, data: new Blob([JSON.stringify({ detail: 'دسترسی ندارید.' })]), headers: {}, config }); };
await assert.rejects(h.catalog.downloadCatalog('products'), /دسترسی ندارید/); assert.equal(h.downloads.length, 0);
});
test('sidebar only offers product management for explicit admin capability', () => {
const h = harness(), Sidebar = h.load('src/app/(index)/components/side-right.tsx').default;
h.queries.set('cuser-info', { data: { data: { permissions: { manage_products: false } } } });
assert.doesNotMatch(text(h.render(Sidebar)), /مدیریت محصولات/);
h.queries.set('cuser-info', { data: { data: { permissions: { manage_products: true } } } });
assert.match(text(h.render(Sidebar)), /مدیریت محصولات/);
});