mobile-crm-record-identity.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import * as vue from 'vue'
  7. function deferred() {
  8. let resolve!: (value?: any) => void
  9. let reject!: (error: any) => void
  10. const promise = new Promise<any>((a, b) => {
  11. resolve = a
  12. reject = b
  13. })
  14. return { promise, resolve, reject }
  15. }
  16. const flush = async () => {
  17. for (let i = 0; i < 20; i++) await Promise.resolve()
  18. }
  19. function harness(page: string, overrides: Record<string, any> = {}) {
  20. const content = parse(
  21. readFileSync(new URL(`../src/views/m/crm/${page}.vue`, import.meta.url), 'utf8')
  22. ).descriptor.scriptSetup!.content
  23. const ast = ts.createSourceFile('page.ts', content, ts.ScriptTarget.Latest, true)
  24. const route = vue.reactive({
  25. name: page === 'customer/detail' ? 'MpCrmCustomerDetail' : undefined,
  26. params: { id: 'A' },
  27. query: {} as Record<string, string>,
  28. fullPath: '/A',
  29. path: '/A'
  30. })
  31. const unmounts: Array<() => void> = []
  32. const writes: any[] = [],
  33. pushes: any[] = [],
  34. calls: string[] = []
  35. const bindings: Record<string, any> = Object.fromEntries(
  36. Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
  37. )
  38. for (const s of ast.statements) {
  39. if (!ts.isImportDeclaration(s)) continue
  40. const clause = s.importClause
  41. if (clause?.name) bindings[clause.name.text] = () => undefined
  42. if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
  43. for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
  44. }
  45. const read = async (arg: any) => {
  46. const id = typeof arg === 'string' ? arg : arg.id
  47. calls.push(id)
  48. return { data: { id, status: 'draft', products: [], productList: [] } }
  49. }
  50. Object.assign(bindings, {
  51. useRoute: () => route,
  52. useRouter: () => ({
  53. push: (v: any) => pushes.push(v),
  54. replace: async (v: any) => {
  55. if (typeof v === 'object') route.query = v.query
  56. else pushes.push(v)
  57. }
  58. }),
  59. useUserStore: () => ({ info: { id: 'user' } }),
  60. useDictStore: () => ({ getLabel: () => '', getTagType: () => '' }),
  61. onMounted() {},
  62. onUnmounted: (fn: any) => unmounts.push(fn),
  63. onBeforeUnmount: (fn: any) => unmounts.push(fn),
  64. getContract: read,
  65. getQuote: read,
  66. fetchCustomerDetail: read,
  67. fetchProductDetail: read,
  68. listContractApproval: async () => ({ data: [] }),
  69. listApproval: async () => ({ data: [] }),
  70. listQuoteProduct: async () => ({ rows: [] }),
  71. fetchCategoryTree: async () => ({ data: [] }),
  72. buildPathLabelMapFromTreeRoots: () => new Map(),
  73. unwrapData: (v: any) => v.data ?? v,
  74. customerForm: (v: any) => v,
  75. canDeleteByCreator: () => true,
  76. isContractEditableStatus: () => true,
  77. ElMessage: { warning() {}, error() {}, success() {} },
  78. showCrmConfirmDialog: async () => {},
  79. showProductConfirmDialog: async () => {},
  80. fetchDeleteCustomer: async (v: any) => writes.push(v),
  81. fetchDeleteProduct: async (v: any) => writes.push(v),
  82. delContract: async (v: any) => writes.push(v),
  83. delQuote: async (v: any) => writes.push(v),
  84. fetchUpdateProductStatus: async (v: any) => writes.push(v),
  85. defineExpose() {},
  86. defineProps: () => vue.reactive({ modelValue: true, ids: ['A'] }),
  87. defineEmits:
  88. () =>
  89. (...v: any[]) =>
  90. pushes.push(v),
  91. fetchIntoPool: async (v: any) => writes.push(v),
  92. document: { querySelector: () => null },
  93. window: { removeEventListener() {}, scrollTo() {} },
  94. ...overrides
  95. })
  96. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  97. const names = statements.flatMap((s) =>
  98. ts.isFunctionDeclaration(s)
  99. ? [s.name!.text]
  100. : ts.isVariableStatement(s)
  101. ? s.declarationList.declarations.map((d) => d.name.getText(ast))
  102. : []
  103. )
  104. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  105. target: ts.ScriptTarget.ESNext
  106. })
  107. const scope = vue.effectScope()
  108. const api: any = scope.run(() =>
  109. new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
  110. ...Object.values(bindings)
  111. )
  112. )
  113. return {
  114. ...api,
  115. route,
  116. calls,
  117. writes,
  118. pushes,
  119. ready: api.onShellReady || api.onReady,
  120. record: api.contract || api.quote || api.customer || api.detail,
  121. unmount: () => {
  122. unmounts.forEach((fn) => fn())
  123. scope.stop()
  124. }
  125. }
  126. }
  127. for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) {
  128. test(`${page}: ready gate, immediate identity reset and one read per ID`, async () => {
  129. const h = harness(page)
  130. h.route.params.id = 'B'
  131. await flush()
  132. assert.deepEqual(h.calls, [])
  133. h.ready()
  134. await flush()
  135. assert.deepEqual(h.calls, ['B'])
  136. h.ready()
  137. await flush()
  138. assert.deepEqual(h.calls, ['B'])
  139. h.route.params.id = 'C'
  140. assert.ok(!h.record.value)
  141. await flush()
  142. assert.deepEqual(h.calls, ['B', 'C'])
  143. h.route.query.tab = 'products'
  144. h.route.fullPath = '/C?tab=products'
  145. await flush()
  146. assert.deepEqual(h.calls, ['B', 'C'])
  147. h.unmount()
  148. })
  149. test(`${page}: mismatched successful response is never actionable`, async () => {
  150. const bad = async () => ({ data: { id: 'WRONG' } })
  151. const h = harness(page, {
  152. getContract: bad,
  153. getQuote: bad,
  154. fetchCustomerDetail: bad,
  155. fetchProductDetail: bad
  156. })
  157. h.ready()
  158. await flush()
  159. assert.ok(!h.record.value)
  160. h.unmount()
  161. })
  162. test(`${page}: confirmation from A cannot delete after switching`, async () => {
  163. const confirmation = deferred()
  164. const h = harness(page, {
  165. showCrmConfirmDialog: () => confirmation.promise,
  166. showProductConfirmDialog: () => confirmation.promise
  167. })
  168. h.ready()
  169. await flush()
  170. const pending = (h.remove || h.onDelete)()
  171. h.route.params.id = 'B'
  172. await flush()
  173. confirmation.resolve()
  174. await pending
  175. assert.deepEqual(h.writes, [])
  176. h.unmount()
  177. })
  178. }
  179. test('pool: changed targets during confirmation cannot submit', async () => {
  180. const confirmation = deferred(),
  181. props = vue.reactive({ modelValue: true, ids: ['A'] })
  182. const h = harness('customer/CustomerPoolSheet', {
  183. defineProps: () => props,
  184. showCrmConfirmDialog: () => confirmation.promise
  185. })
  186. h.reason.value = 'reason'
  187. const pending = h.submit()
  188. props.ids = ['B']
  189. confirmation.resolve()
  190. await pending
  191. assert.deepEqual(h.writes, [])
  192. h.unmount()
  193. })
  194. for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) {
  195. for (const outcome of ['success', 'failure', 'ABA', 'unmount']) {
  196. test(`${page}: delayed ${outcome} cannot publish into a newer session`, async () => {
  197. const old = deferred()
  198. let first = true
  199. const read = async (arg: any) => {
  200. const id = typeof arg === 'string' ? arg : arg.id
  201. if (first) {
  202. first = false
  203. return old.promise
  204. }
  205. return { data: { id, marker: 'fresh', status: 'draft' } }
  206. }
  207. const h = harness(page, {
  208. getContract: read,
  209. getQuote: read,
  210. fetchCustomerDetail: read,
  211. fetchProductDetail: read
  212. })
  213. h.ready()
  214. await flush()
  215. if (outcome === 'unmount') h.unmount()
  216. else {
  217. h.route.params.id = 'B'
  218. await flush()
  219. assert.equal(h.record.value.id, 'B')
  220. if (outcome === 'ABA') {
  221. h.route.params.id = 'A'
  222. await flush()
  223. }
  224. }
  225. if (outcome === 'failure') old.reject(Error('old failure'))
  226. else old.resolve({ data: { id: 'A', marker: 'old' } })
  227. await flush()
  228. if (outcome === 'unmount') assert.ok(!h.record.value)
  229. else {
  230. assert.equal(h.record.value.marker, 'fresh')
  231. assert.equal((h.errorText || h.error).value, '')
  232. assert.equal(h.loading.value, false)
  233. h.unmount()
  234. }
  235. })
  236. }
  237. test(`${page}: old sent delete cannot navigate or clear new operation busy`, async () => {
  238. const sent = deferred(),
  239. fresh = deferred()
  240. let count = 0
  241. const write = () => (++count === 1 ? sent.promise : fresh.promise)
  242. const h = harness(page, {
  243. delContract: write,
  244. delQuote: write,
  245. fetchDeleteCustomer: write,
  246. fetchDeleteProduct: write
  247. })
  248. h.ready()
  249. await flush()
  250. const old = (h.remove || h.onDelete)()
  251. await flush()
  252. assert.equal(count, 1)
  253. h.route.params.id = 'B'
  254. await flush()
  255. const current = (h.remove || h.onDelete)()
  256. await flush()
  257. assert.equal(count, 2)
  258. sent.resolve()
  259. await old
  260. assert.deepEqual(h.pushes, [])
  261. assert.equal((h.acting || h.deleting).value, true)
  262. fresh.resolve()
  263. await current
  264. assert.equal(h.pushes.length, 1)
  265. h.unmount()
  266. })
  267. }
  268. for (const page of ['contract/detail', 'quote/detail']) {
  269. test(`${page}: mode change invalidates confirmation and query-only changes preserve submit`, async () => {
  270. const confirmation = deferred()
  271. const h = harness(page, {
  272. showCrmConfirmDialog: () => confirmation.promise,
  273. fetchUserOptions: async () => []
  274. })
  275. h.ready()
  276. await flush()
  277. await h.openSubmitSheet()
  278. h.route.query.tab = 'products'
  279. h.route.fullPath = '/A?tab=products'
  280. await flush()
  281. assert.equal(h.submitOpen.value, true)
  282. const pending = h.onDelete()
  283. h.route.query.from = 'approve'
  284. assert.equal(h.submitOpen.value, false)
  285. confirmation.resolve()
  286. await pending
  287. assert.deepEqual(h.writes, [])
  288. await h.onDelete()
  289. assert.deepEqual(h.writes, [])
  290. assert.equal(h.calls.length, 1)
  291. h.unmount()
  292. })
  293. test(`${page}: route action consumes own query once and preserves new sheet`, async () => {
  294. const h = harness(page, { fetchUserOptions: async () => [] })
  295. h.route.query.action = 'submit'
  296. h.ready()
  297. await flush()
  298. assert.equal(h.route.query.action, undefined)
  299. assert.equal(h.submitOpen.value, true)
  300. assert.deepEqual(h.calls, ['A'])
  301. await h.runRouteAction()
  302. assert.equal(h.submitOpen.value, true)
  303. h.unmount()
  304. })
  305. test(`${page}: route action awaiting replace cannot open on another record or mode`, async () => {
  306. const replacing = deferred()
  307. const h = harness(page, { useRouter: () => ({ replace: () => replacing.promise, push() {} }) })
  308. h.ready()
  309. await flush()
  310. h.route.query.action = 'submit'
  311. const pending = h.runRouteAction()
  312. h.route.query.from = 'approve'
  313. replacing.resolve()
  314. await pending
  315. assert.equal(h.submitOpen.value, false)
  316. h.unmount()
  317. })
  318. }
  319. for (const stage of ['products', 'enrichment', 'approval']) {
  320. test(`quote: delayed ${stage} publishes only local current-record values`, async () => {
  321. const delayed = deferred()
  322. const h = harness('quote/detail', {
  323. getQuote: async (id: string) => ({
  324. data: { id, productList: stage === 'products' ? [] : [{ productId: id, name: id }] }
  325. }),
  326. listQuoteProduct: (id: string) =>
  327. id === 'A' ? delayed.promise : Promise.resolve({ rows: [{ productId: id, name: id }] }),
  328. enrichQuoteDetailProductMeta: async (lines: any[]) => {
  329. if (stage === 'enrichment' && lines[0]?.productId === 'A') {
  330. await delayed.promise
  331. lines[0].name = 'stale'
  332. }
  333. },
  334. listApproval: (id: string) =>
  335. stage === 'approval' && id === 'A' ? delayed.promise : Promise.resolve({ data: [{ id }] })
  336. })
  337. h.ready()
  338. await flush()
  339. h.route.params.id = 'B'
  340. await flush()
  341. assert.equal(h.products.value[0].name, 'B')
  342. delayed.resolve({ rows: [{ name: 'stale' }], data: [{ id: 'stale' }] })
  343. await flush()
  344. assert.equal(h.record.value.id, 'B')
  345. assert.equal(h.products.value[0].name, 'B')
  346. assert.equal(h.approvalList.value[0].id, 'B')
  347. h.unmount()
  348. })
  349. }
  350. for (const timing of ['picker', 'first upload', 'sent attachment write']) {
  351. test(`contract: ${timing} session is invalidated by switching ID`, async () => {
  352. const delayed = deferred(),
  353. uploads: any[] = [],
  354. attachments: any[] = []
  355. const h = harness('contract/detail', {
  356. parseCrmAttachmentField: () => [],
  357. buildAttachmentUploadFormData: (file: any) => file,
  358. CRM_UPLOAD_BIZ_TYPE: { CONTRACT_ATTACHMENT: 'contract' },
  359. fetchUploadAttachment: (file: any) => {
  360. uploads.push(file)
  361. return timing === 'first upload'
  362. ? delayed.promise
  363. : Promise.resolve({ data: { fileUrl: file } })
  364. },
  365. uploadAttachments: (body: any) => {
  366. attachments.push(body)
  367. return delayed.promise
  368. }
  369. })
  370. h.ready()
  371. await flush()
  372. let clicked = 0
  373. h.fileInputRef.value = { click: () => clicked++ }
  374. await h.runAction('upload')
  375. assert.equal(clicked, 1)
  376. if (timing === 'picker') {
  377. h.route.params.id = 'B'
  378. await flush()
  379. }
  380. const pending = h.onFilesChosen({ target: { files: ['one', 'two'], value: 'selected' } })
  381. await flush()
  382. if (timing !== 'picker') {
  383. h.route.params.id = 'B'
  384. await flush()
  385. }
  386. h.acting.value = true
  387. delayed.resolve({ data: { fileUrl: 'one' } })
  388. await pending
  389. assert.equal(uploads.length, timing === 'picker' ? 0 : timing === 'first upload' ? 1 : 2)
  390. assert.equal(attachments.length, timing === 'sent attachment write' ? 1 : 0)
  391. assert.equal(h.record.value.id, 'B')
  392. assert.equal(h.acting.value, true)
  393. assert.deepEqual(h.calls, ['A', 'B'])
  394. h.unmount()
  395. })
  396. }
  397. for (const invalidation of ['close', 'prop close', 'unmount', 'ABA']) {
  398. test(`pool: ${invalidation} invalidates waiting confirmation`, async () => {
  399. const confirmation = deferred(),
  400. props = vue.reactive({ modelValue: true, ids: ['A'] })
  401. const h = harness('customer/CustomerPoolSheet', {
  402. defineProps: () => props,
  403. showCrmConfirmDialog: () => confirmation.promise
  404. })
  405. h.reason.value = 'reason'
  406. const pending = h.submit()
  407. if (invalidation === 'close') h.close()
  408. if (invalidation === 'prop close') props.modelValue = false
  409. if (invalidation === 'unmount') h.unmount()
  410. if (invalidation === 'ABA') {
  411. props.ids = ['B']
  412. props.ids = ['A']
  413. }
  414. confirmation.resolve()
  415. await pending
  416. assert.deepEqual(h.writes, [])
  417. if (invalidation !== 'unmount') h.unmount()
  418. })
  419. }
  420. test('pool: captured reason and ids are stable; sent stale result cannot emit success', async () => {
  421. const confirm = deferred(),
  422. sent = deferred(),
  423. writes: any[] = []
  424. const props = vue.reactive({ modelValue: true, ids: ['A'] })
  425. const h = harness('customer/CustomerPoolSheet', {
  426. defineProps: () => props,
  427. showCrmConfirmDialog: () => confirm.promise,
  428. fetchIntoPool: (body: any) => {
  429. writes.push(body)
  430. return sent.promise
  431. }
  432. })
  433. h.reason.value = 'original'
  434. const pending = h.submit()
  435. h.reason.value = 'changed'
  436. confirm.resolve()
  437. await flush()
  438. assert.deepEqual(writes, [{ ids: ['A'], reason: 'original' }])
  439. props.ids = ['B']
  440. h.saving.value = true
  441. sent.resolve()
  442. await pending
  443. assert.equal(h.saving.value, true)
  444. assert.deepEqual(h.pushes, [])
  445. h.unmount()
  446. })
  447. for (const page of ['contract/detail', 'quote/detail', 'customer/detail', 'product/detail']) {
  448. test(`${page}: confirmation rechecks loading before write`, async () => {
  449. const confirmation = deferred()
  450. const h = harness(page, {
  451. showCrmConfirmDialog: () => confirmation.promise,
  452. showProductConfirmDialog: () => confirmation.promise
  453. })
  454. h.ready()
  455. await flush()
  456. const pending = (h.remove || h.onDelete)()
  457. h.loading.value = true
  458. confirmation.resolve()
  459. await pending
  460. assert.deepEqual(h.writes, [])
  461. h.unmount()
  462. })
  463. }
  464. test('quote: approval writes the pending approval ID, never the quote ID', async () => {
  465. const writes: any[] = []
  466. const h = harness('quote/detail', {
  467. getQuote: async (id: string) => ({ data: { id, status: 'submitted' } }),
  468. listApproval: async () => ({ data: [{ id: 'approval-record', approverId: 'user' }] }),
  469. findPendingQuoteApproval: (rows: any[]) => rows[0],
  470. approveQuote: async (body: any) => writes.push(body)
  471. })
  472. h.route.query.from = 'approve'
  473. h.ready()
  474. await flush()
  475. await h.runAction('approve')
  476. await h.confirmApprove()
  477. assert.equal(writes.length, 1)
  478. assert.equal(writes[0].id, 'approval-record')
  479. assert.equal(h.acting.value, false)
  480. h.unmount()
  481. })
  482. test('customer: direct edit requires loaded current identity', async () => {
  483. const h = harness('customer/detail')
  484. h.ready()
  485. await flush()
  486. h.customer.value = { id: 'WRONG' }
  487. h.goEdit()
  488. assert.deepEqual(h.pushes, [])
  489. h.customer.value = { id: 'A' }
  490. h.loading.value = true
  491. h.goEdit()
  492. assert.deepEqual(h.pushes, [])
  493. h.unmount()
  494. })
  495. test('pool: equivalent ids replacement preserves reason and pending confirmation', async () => {
  496. const confirmation = deferred()
  497. const props = vue.reactive({ modelValue: true, ids: ['A'] })
  498. const h = harness('customer/CustomerPoolSheet', {
  499. defineProps: () => props,
  500. showCrmConfirmDialog: () => confirmation.promise
  501. })
  502. h.reason.value = '保留原因'
  503. const pending = h.submit()
  504. assert.equal(h.saving.value, true)
  505. props.ids = ['A']
  506. assert.equal(h.reason.value, '保留原因')
  507. assert.equal(h.saving.value, true)
  508. confirmation.resolve()
  509. await pending
  510. assert.deepEqual(h.writes, [{ ids: ['A'], reason: '保留原因' }])
  511. assert.equal(h.pushes.filter((event: any[]) => event[0] === 'success').length, 1)
  512. assert.equal(h.saving.value, false)
  513. h.unmount()
  514. })