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

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