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)), /مدیریت محصولات/); });