teamBackgroundFile.test.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
  1. const assert = require('node:assert/strict')
  2. const fs = require('node:fs')
  3. const path = require('node:path')
  4. const { pathToFileURL } = require('node:url')
  5. const vm = require('node:vm')
  6. const rules = require('../utils/teamBackgroundFile')
  7. assert.equal(rules.validateEnterpriseWebsite(' 无 '), '')
  8. assert.equal(rules.validateEnterpriseWebsite('https://example.com'), '')
  9. assert.match(rules.validateEnterpriseWebsite('ftp://example.com'), /http/)
  10. assert.equal(rules.validateBackgroundFile({ name: 'brief.PDF', size: 1 }), '')
  11. assert.match(rules.validateBackgroundFile({ name: 'brief.zip', size: 1 }), /类型/)
  12. assert.match(rules.validateBackgroundFile({ name: 'brief.pdf', size: 50 * 1024 * 1024 + 1 }), /50MB/)
  13. assert.equal(rules.validateBackgroundFile({ name: 'brief.pdf', size: 50 * 1024 * 1024 }), '')
  14. assert.equal(rules.validateBackgroundFile({ name: `${'a'.repeat(251)}.pdf`, size: 1 }), '')
  15. assert.match(rules.validateBackgroundFile({ name: `${'a'.repeat(252)}.pdf`, size: 1 }), /255/)
  16. assert.match(rules.validateBackgroundFile({ name: 'empty.pdf', size: 0 }), /非空/)
  17. const backendExpiry = '2026-07-21 15:30:00'
  18. const explicitOffsetExpiry = '2026-07-21T15:30:00+08:00'
  19. const utcExpiry = '2026-07-21T07:30:00.250Z'
  20. assert.equal(rules.parseSessionExpiresAt(backendExpiry), Date.UTC(2026, 6, 21, 7, 30, 0))
  21. assert.equal(rules.parseSessionExpiresAt(explicitOffsetExpiry), Date.UTC(2026, 6, 21, 7, 30, 0))
  22. assert.equal(rules.parseSessionExpiresAt(utcExpiry), Date.UTC(2026, 6, 21, 7, 30, 0, 250))
  23. assert.equal(rules.getSessionRemainingSeconds(backendExpiry, Date.UTC(2026, 6, 21, 7, 29, 59, 1)), 1)
  24. assert.equal(rules.getSessionRemainingSeconds(backendExpiry, Date.UTC(2026, 6, 21, 7, 30, 1)), 0)
  25. assert.equal(rules.getSessionRemainingSeconds('2026-07-21T15:30:00', 0), 0)
  26. assert.equal(rules.getSessionRemainingSeconds('not-a-date', 0), 0)
  27. const validQueueItem = rules.createBackgroundQueueItem(
  28. { name: 'brief.pdf', path: '/tmp/brief.pdf', size: 1 },
  29. 'valid-file'
  30. )
  31. const invalidQueueItem = rules.createBackgroundQueueItem(
  32. { name: 'archive.zip', path: '/tmp/archive.zip', size: 1 },
  33. 'invalid-file'
  34. )
  35. assert.equal(validQueueItem.status, 'pending')
  36. assert.equal(validQueueItem.progress, 0)
  37. assert.equal(validQueueItem.validationError, '')
  38. assert.equal(invalidQueueItem.status, 'failed')
  39. assert.match(invalidQueueItem.validationError, /类型/)
  40. validQueueItem.status = 'success'
  41. const uploadFailure = {
  42. id: 'upload-failure',
  43. status: 'failed',
  44. validationError: '',
  45. error: '上传失败'
  46. }
  47. assert.deepEqual(
  48. rules.getUploadableBackgroundFiles([validQueueItem, invalidQueueItem, uploadFailure]),
  49. [uploadFailure]
  50. )
  51. const originalTeam = {
  52. id: 12,
  53. teamName: '研发团队',
  54. enterpriseWebsite: ' https://example.com/team ',
  55. coachId: 3,
  56. uploaderId: 4,
  57. source: 'WECHAT'
  58. }
  59. const teamSubmitPayload = rules.createTeamSubmitPayload(originalTeam)
  60. assert.equal(teamSubmitPayload.id, 12)
  61. assert.equal(teamSubmitPayload.enterpriseWebsite, 'https://example.com/team')
  62. assert.equal(Object.hasOwn(teamSubmitPayload, 'coachId'), false)
  63. assert.equal(Object.hasOwn(teamSubmitPayload, 'uploaderId'), false)
  64. assert.equal(Object.hasOwn(teamSubmitPayload, 'source'), false)
  65. assert.equal(originalTeam.coachId, 3, 'payload creation must not mutate the displayed detail')
  66. const textPreviewState = rules.createTextPreviewState({
  67. mode: 'text',
  68. fileName: '背景说明.md',
  69. content: '# 团队背景',
  70. truncated: true
  71. }, 'fallback.md')
  72. assert.deepEqual(textPreviewState, {
  73. visible: true,
  74. fileName: '背景说明.md',
  75. content: '# 团队背景',
  76. truncated: true
  77. })
  78. assert.deepEqual(rules.createEmptyTextPreviewState(), {
  79. visible: false,
  80. fileName: '',
  81. content: '',
  82. truncated: false
  83. })
  84. assert.notEqual(rules.createEmptyTextPreviewState(), rules.createEmptyTextPreviewState())
  85. const creationState = rules.createTeamCreationState()
  86. assert.deepEqual(creationState, {
  87. createdTeamId: '',
  88. submitting: false,
  89. stage: 'idle',
  90. completed: false
  91. })
  92. assert.equal(rules.beginTeamSubmission(creationState), true)
  93. assert.equal(rules.beginTeamSubmission(creationState), false, 'a second submission must be locked')
  94. assert.equal(rules.rememberCreatedTeam(creationState, 88), 88)
  95. assert.equal(rules.rememberCreatedTeam(creationState, 99), 88, 'the first server team id must be retained')
  96. rules.setTeamSubmissionStage(creationState, 'upload')
  97. assert.equal(creationState.stage, 'upload')
  98. rules.finishTeamSubmission(creationState)
  99. assert.equal(creationState.submitting, false)
  100. assert.equal(rules.beginTeamSubmission(creationState), true, 'a failed later stage can resume')
  101. rules.finishTeamSubmission(creationState, true)
  102. assert.equal(creationState.completed, true)
  103. assert.equal(rules.beginTeamSubmission(creationState), false, 'a completed flow cannot be scheduled twice')
  104. assert.equal(rules.hasUnresolvedBackgroundFiles([
  105. { status: 'success', validationError: '' },
  106. { status: 'failed', validationError: '文件类型不支持' }
  107. ]), false)
  108. for (const item of [
  109. { status: 'pending', validationError: '' },
  110. { status: 'uploading', validationError: '' },
  111. { status: 'failed', validationError: '' }
  112. ]) {
  113. assert.equal(rules.hasUnresolvedBackgroundFiles([item]), true)
  114. }
  115. console.log('team background rules: PASS')
  116. const transportPath = path.join(__dirname, '../http/teamBackgroundFile.js')
  117. async function loadTransport({ api = {}, uni = {}, wx = {} } = {}) {
  118. const source = fs.readFileSync(transportPath, 'utf8')
  119. const context = vm.createContext({ uni, wx })
  120. const baseApiModule = new vm.SyntheticModule(['BaseApi'], function () {
  121. this.setExport('BaseApi', 'https://api.example.test/app')
  122. }, { context, identifier: 'test:baseApi' })
  123. const apiModule = new vm.SyntheticModule(['default'], function () {
  124. this.setExport('default', api)
  125. }, { context, identifier: 'test:api' })
  126. const transportModule = new vm.SourceTextModule(source, {
  127. context,
  128. identifier: pathToFileURL(transportPath).href
  129. })
  130. await transportModule.link(specifier => {
  131. if (specifier === './baseApi.js') return baseApiModule
  132. if (specifier === './index.js') return apiModule
  133. throw new Error(`unexpected transport import: ${specifier}`)
  134. })
  135. await transportModule.evaluate()
  136. return transportModule.namespace
  137. }
  138. function assertNoIdentityFields(value) {
  139. if (!value || typeof value !== 'object') return
  140. for (const [key, child] of Object.entries(value)) {
  141. assert.ok(!['source', 'coachId', 'uploaderId'].includes(key), `unexpected identity field: ${key}`)
  142. assertNoIdentityFields(child)
  143. }
  144. }
  145. async function createUploadHarness(response, progress = 37) {
  146. let request
  147. const progressValues = []
  148. const uni = {
  149. getStorageSync(key) {
  150. assert.equal(key, 'token')
  151. return 'token-123'
  152. },
  153. uploadFile(options) {
  154. request = options
  155. return {
  156. onProgressUpdate(callback) {
  157. callback({ progress })
  158. setImmediate(() => options.success(response))
  159. }
  160. }
  161. }
  162. }
  163. const transport = await loadTransport({ uni })
  164. return {
  165. request: () => request,
  166. progressValues,
  167. promise: transport.uploadTeamBackgroundFile(12, '/tmp/brief.pdf', value => progressValues.push(value))
  168. }
  169. }
  170. async function testUploadTransport() {
  171. const stringHarness = await createUploadHarness({
  172. statusCode: 200,
  173. data: JSON.stringify({ code: 0, data: { id: 7 } })
  174. })
  175. assert.equal((await stringHarness.promise).id, 7)
  176. assert.equal(stringHarness.request().url, 'https://api.example.test/app/core/user/team/12/background-files')
  177. assert.equal(stringHarness.request().filePath, '/tmp/brief.pdf')
  178. assert.equal(stringHarness.request().name, 'file')
  179. assert.equal(stringHarness.request().header.token, 'token-123')
  180. assert.deepEqual(stringHarness.progressValues, [37])
  181. assertNoIdentityFields(stringHarness.request())
  182. const objectHarness = await createUploadHarness({ statusCode: 200, data: { code: 0, data: { id: 8 } } })
  183. assert.equal((await objectHarness.promise).id, 8)
  184. const statusHarness = await createUploadHarness({ statusCode: 500, data: { code: 0 } })
  185. await assert.rejects(statusHarness.promise, /上传失败,请重试/)
  186. const codeHarness = await createUploadHarness({ statusCode: 200, data: { code: 9, msg: '上传业务失败' } })
  187. await assert.rejects(codeHarness.promise, /上传业务失败/)
  188. const invalidJsonHarness = await createUploadHarness({ statusCode: 200, data: '<invalid-json>' })
  189. await assert.rejects(invalidJsonHarness.promise, /上传失败,请重试/)
  190. }
  191. async function testApiTransport() {
  192. const calls = []
  193. const api = {
  194. get(...args) {
  195. calls.push(['get', ...args])
  196. return Promise.resolve({ data: { code: 0, data: [{ id: 1 }] } })
  197. },
  198. del(...args) {
  199. calls.push(['del', ...args])
  200. return Promise.resolve({ data: { code: 0, data: true } })
  201. },
  202. post(...args) {
  203. calls.push(['post', ...args])
  204. return Promise.resolve({ data: { code: 0, data: { sessionId: 'session-1' } } })
  205. }
  206. }
  207. const transport = await loadTransport({ api })
  208. assert.deepEqual(await transport.listTeamBackgroundFiles(12), [{ id: 1 }])
  209. assert.equal(await transport.disableTeamBackgroundFile(12, 34), true)
  210. assert.deepEqual(await transport.createTeamBackgroundSession(12), { sessionId: 'session-1' })
  211. assert.equal(calls[0][0], 'get')
  212. assert.equal(calls[0][1], '/core/user/team/12/background-files')
  213. assert.equal(calls[1][0], 'del')
  214. assert.equal(calls[1][1], '/core/user/team/12/background-files/34')
  215. assert.equal(calls[2][0], 'post')
  216. assert.equal(calls[2][1], '/core/user/team/12/background-upload-session')
  217. for (const call of calls) {
  218. assert.equal(Object.keys(call[2]).length, 0)
  219. assert.equal(call[3], false)
  220. assertNoIdentityFields(call)
  221. }
  222. const failingTransport = await loadTransport({
  223. api: {
  224. get: () => Promise.resolve({ data: { code: 3, msg: '列表业务失败' } })
  225. }
  226. })
  227. await assert.rejects(failingTransport.listTeamBackgroundFiles(12), /列表业务失败/)
  228. assert.equal(transport.uploadTeamBackgroundFile.length, 2)
  229. assert.equal(transport.listTeamBackgroundFiles.length, 1)
  230. assert.equal(transport.disableTeamBackgroundFile.length, 2)
  231. assert.equal(transport.createTeamBackgroundSession.length, 1)
  232. assert.equal(transport.downloadTeamBackgroundFile.length, 2)
  233. }
  234. const TEXT_PREVIEW_LIMIT = 256 * 1024
  235. async function createDownloadHarness({ result, responseHeaders, downloadError, openError,
  236. fileSize, fileContent = '', fileInfoError, readError, withHeadersListener = true } = {}) {
  237. let request
  238. let openRequest
  239. let fileInfoRequest
  240. let readRequest
  241. let fileSystemManagerCalls = 0
  242. const resolvedFileSize = fileSize === undefined
  243. ? Buffer.byteLength(fileContent, 'utf8')
  244. : fileSize
  245. const uni = {
  246. getStorageSync(key) {
  247. assert.equal(key, 'token')
  248. return 'token-456'
  249. },
  250. downloadFile(options) {
  251. request = options
  252. setImmediate(() => {
  253. if (downloadError) options.fail(downloadError)
  254. else options.success(result)
  255. })
  256. if (!withHeadersListener) return {}
  257. return {
  258. onHeadersReceived(callback) {
  259. if (responseHeaders) callback({ header: responseHeaders })
  260. }
  261. }
  262. }
  263. }
  264. const wx = {
  265. openDocument(options) {
  266. openRequest = options
  267. setImmediate(() => {
  268. if (openError) options.fail(openError)
  269. else options.success('opened')
  270. })
  271. },
  272. getFileSystemManager() {
  273. fileSystemManagerCalls += 1
  274. return {
  275. getFileInfo(options) {
  276. fileInfoRequest = options
  277. setImmediate(() => {
  278. if (fileInfoError) options.fail(fileInfoError)
  279. else options.success({ size: resolvedFileSize })
  280. })
  281. },
  282. readFile(options) {
  283. readRequest = options
  284. setImmediate(() => {
  285. if (readError) options.fail(readError)
  286. else options.success({ data: fileContent })
  287. })
  288. }
  289. }
  290. }
  291. }
  292. const transport = await loadTransport({ uni, wx })
  293. return {
  294. request: () => request,
  295. openRequest: () => openRequest,
  296. fileInfoRequest: () => fileInfoRequest,
  297. readRequest: () => readRequest,
  298. fileSystemManagerCalls: () => fileSystemManagerCalls,
  299. promise: transport.downloadTeamBackgroundFile(12, 34)
  300. }
  301. }
  302. async function testDownloadTransport() {
  303. const successHarness = await createDownloadHarness({
  304. result: { statusCode: 200, tempFilePath: '/tmp/download.pdf' },
  305. responseHeaders: {
  306. 'cOnTeNt-TyPe': 'application/pdf',
  307. 'CONTENT-disPOSITION': 'attachment; filename="download.pdf"'
  308. }
  309. })
  310. const documentPreview = await successHarness.promise
  311. assert.equal(documentPreview.mode, 'document')
  312. assert.equal(documentPreview.fileName, 'download.pdf')
  313. assert.equal(documentPreview.filePath, '/tmp/download.pdf')
  314. assert.equal(successHarness.request().url, 'https://api.example.test/app/core/user/team/12/background-files/34/download')
  315. assert.equal(successHarness.request().header.token, 'token-456')
  316. assert.equal(successHarness.openRequest(), undefined, 'transport must not open a document before component context checks')
  317. assertNoIdentityFields(successHarness.request())
  318. const resultHeaderHarness = await createDownloadHarness({
  319. result: {
  320. statusCode: 200,
  321. tempFilePath: '/tmp/result-header.docx',
  322. header: {
  323. 'content-type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  324. 'content-disposition': 'attachment; filename="result-header.docx"'
  325. }
  326. },
  327. withHeadersListener: false
  328. })
  329. const resultHeaderPreview = await resultHeaderHarness.promise
  330. assert.equal(resultHeaderPreview.mode, 'document')
  331. assert.equal(resultHeaderPreview.fileName, 'result-header.docx')
  332. assert.equal(resultHeaderPreview.filePath, '/tmp/result-header.docx')
  333. assert.equal(resultHeaderHarness.openRequest(), undefined)
  334. assert.equal(resultHeaderHarness.fileSystemManagerCalls(), 0)
  335. const txtHarness = await createDownloadHarness({
  336. result: {
  337. statusCode: 200,
  338. tempFilePath: '/tmp/text-preview.txt',
  339. headers: {
  340. 'Content-Type': 'text/plain; charset=UTF-8',
  341. 'Content-Disposition': 'attachment; filename="fallback.txt"; '
  342. + "filename*=UTF-8''%E8%83%8C%E6%99%AF%20%E8%B5%84%E6%96%99.txt"
  343. }
  344. },
  345. fileSize: 5,
  346. fileContent: 'hello',
  347. withHeadersListener: false
  348. })
  349. const txtPreview = await txtHarness.promise
  350. assert.equal(txtHarness.openRequest(), undefined, 'txt must not call openDocument')
  351. assert.equal(txtPreview.mode, 'text')
  352. assert.equal(txtPreview.fileName, '背景 资料.txt')
  353. assert.equal(txtPreview.content, 'hello')
  354. assert.equal(txtPreview.truncated, false)
  355. assert.equal(txtHarness.fileInfoRequest().filePath, '/tmp/text-preview.txt')
  356. assert.equal(txtHarness.readRequest().filePath, '/tmp/text-preview.txt')
  357. assert.equal(txtHarness.readRequest().encoding, 'utf8')
  358. assert.equal(txtHarness.readRequest().position, 0)
  359. assert.equal(txtHarness.readRequest().length, 5)
  360. const emptyTextHarness = await createDownloadHarness({
  361. result: { statusCode: 200, tempFilePath: '/tmp/empty.txt' },
  362. responseHeaders: {
  363. 'Content-Type': 'text/plain',
  364. 'Content-Disposition': 'attachment; filename="empty.txt"'
  365. },
  366. fileSize: 0
  367. })
  368. const emptyTextPreview = await emptyTextHarness.promise
  369. assert.equal(emptyTextPreview.mode, 'text')
  370. assert.equal(emptyTextPreview.fileName, 'empty.txt')
  371. assert.equal(emptyTextPreview.content, '')
  372. assert.equal(emptyTextPreview.truncated, false)
  373. assert.equal(emptyTextHarness.readRequest(), undefined)
  374. const invalidEncodedNameHarness = await createDownloadHarness({
  375. result: { statusCode: 200, tempFilePath: '/tmp/fallback.txt' },
  376. responseHeaders: {
  377. 'Content-Type': 'text/plain',
  378. 'Content-Disposition': 'attachment; filename="fallback.txt"; '
  379. + "filename*=UTF-8''%E0%A4%A"
  380. },
  381. fileSize: 8,
  382. fileContent: 'fallback'
  383. })
  384. const invalidEncodedNamePreview = await invalidEncodedNameHarness.promise
  385. assert.equal(invalidEncodedNamePreview.fileName, 'fallback.txt')
  386. assert.equal(invalidEncodedNamePreview.content, 'fallback')
  387. const mdContent = 'm'.repeat(TEXT_PREVIEW_LIMIT)
  388. const mdHarness = await createDownloadHarness({
  389. result: { statusCode: 200, tempFilePath: '/tmp/guide.md' },
  390. responseHeaders: {
  391. 'Content-Type': 'text/markdown; charset=UTF-8',
  392. 'Content-Disposition': 'attachment; filename="guide.md"'
  393. },
  394. fileSize: 50 * 1024 * 1024,
  395. fileContent: mdContent
  396. })
  397. const mdPreview = await mdHarness.promise
  398. assert.equal(mdHarness.openRequest(), undefined)
  399. assert.equal(mdPreview.mode, 'text')
  400. assert.equal(mdPreview.fileName, 'guide.md')
  401. assert.equal(mdPreview.content.length, TEXT_PREVIEW_LIMIT)
  402. assert.equal(mdPreview.truncated, true)
  403. assert.equal(mdHarness.readRequest().length, TEXT_PREVIEW_LIMIT)
  404. const csvContent = 'c'.repeat(TEXT_PREVIEW_LIMIT)
  405. const csvHarness = await createDownloadHarness({
  406. result: { statusCode: 200, tempFilePath: '/tmp/data.csv' },
  407. responseHeaders: {
  408. 'Content-Type': 'text/csv',
  409. 'Content-Disposition': 'attachment; filename=data.csv'
  410. },
  411. fileSize: TEXT_PREVIEW_LIMIT,
  412. fileContent: csvContent
  413. })
  414. const csvPreview = await csvHarness.promise
  415. assert.equal(csvHarness.openRequest(), undefined)
  416. assert.equal(csvPreview.mode, 'text')
  417. assert.equal(csvPreview.fileName, 'data.csv')
  418. assert.equal(csvPreview.content.length, TEXT_PREVIEW_LIMIT)
  419. assert.equal(csvPreview.truncated, false)
  420. assert.equal(csvHarness.readRequest().length, TEXT_PREVIEW_LIMIT)
  421. const fileInfoFailureHarness = await createDownloadHarness({
  422. result: { statusCode: 200, tempFilePath: '/tmp/file-info-failure.txt' },
  423. responseHeaders: {
  424. 'Content-Type': 'text/plain',
  425. 'Content-Disposition': 'attachment; filename="file-info-failure.txt"'
  426. },
  427. fileInfoError: new Error('getFileInfo failed')
  428. })
  429. await assert.rejects(fileInfoFailureHarness.promise, /文件读取失败,请重试/)
  430. assert.equal(fileInfoFailureHarness.openRequest(), undefined)
  431. assert.equal(fileInfoFailureHarness.readRequest(), undefined)
  432. const readFailureHarness = await createDownloadHarness({
  433. result: { statusCode: 200, tempFilePath: '/tmp/read-failure.md' },
  434. responseHeaders: {
  435. 'Content-Type': 'text/markdown',
  436. 'Content-Disposition': 'attachment; filename="read-failure.md"'
  437. },
  438. fileSize: 10,
  439. readError: new Error('readFile failed')
  440. })
  441. await assert.rejects(readFailureHarness.promise, /文件读取失败,请重试/)
  442. assert.equal(readFailureHarness.openRequest(), undefined)
  443. const unknownExtensionHarness = await createDownloadHarness({
  444. result: { statusCode: 200, tempFilePath: '/tmp/unknown-file' },
  445. responseHeaders: {
  446. 'Content-Type': 'application/octet-stream',
  447. 'Content-Disposition': 'attachment; filename="archive.zip"'
  448. }
  449. })
  450. await assert.rejects(unknownExtensionHarness.promise, /文件类型不支持预览/)
  451. assert.equal(unknownExtensionHarness.openRequest(), undefined)
  452. assert.equal(unknownExtensionHarness.fileSystemManagerCalls(), 0)
  453. const jsonHarness = await createDownloadHarness({
  454. result: { statusCode: 200, tempFilePath: '/tmp/error.json' },
  455. responseHeaders: {
  456. 'Content-Type': 'application/json;charset=UTF-8',
  457. 'Content-Disposition': 'attachment; filename="error.json"'
  458. }
  459. })
  460. await assert.rejects(jsonHarness.promise, /下载失败,请重试/)
  461. assert.equal(jsonHarness.openRequest(), undefined)
  462. const htmlHarness = await createDownloadHarness({
  463. result: { statusCode: 200, tempFilePath: '/tmp/error.html' },
  464. responseHeaders: {
  465. 'content-type': 'text/html; charset=UTF-8',
  466. 'content-disposition': 'attachment; filename="error.html"'
  467. }
  468. })
  469. await assert.rejects(htmlHarness.promise, /下载失败,请重试/)
  470. assert.equal(htmlHarness.openRequest(), undefined)
  471. const missingHeadersHarness = await createDownloadHarness({
  472. result: { statusCode: 200, tempFilePath: '/tmp/unknown.pdf' },
  473. withHeadersListener: false
  474. })
  475. await assert.rejects(missingHeadersHarness.promise, /下载失败,请重试/)
  476. assert.equal(missingHeadersHarness.openRequest(), undefined)
  477. const statusHarness = await createDownloadHarness({ result: { statusCode: 500 } })
  478. await assert.rejects(statusHarness.promise, /下载失败,请重试/)
  479. assert.equal(statusHarness.openRequest(), undefined)
  480. const downloadError = new Error('download network error')
  481. const downloadFailureHarness = await createDownloadHarness({ downloadError })
  482. await assert.rejects(downloadFailureHarness.promise, /download network error/)
  483. const noTransportOpenHarness = await createDownloadHarness({
  484. result: { statusCode: 200, tempFilePath: '/tmp/download.pdf' },
  485. responseHeaders: {
  486. 'Content-Type': 'application/pdf',
  487. 'Content-Disposition': 'attachment; filename="download.pdf"'
  488. },
  489. openError: new Error('must not be reached')
  490. })
  491. assert.equal((await noTransportOpenHarness.promise).mode, 'document')
  492. assert.equal(noTransportOpenHarness.openRequest(), undefined)
  493. }
  494. function readSfcScript(filePath) {
  495. const source = fs.readFileSync(filePath, 'utf8')
  496. const openingTag = '<script>'
  497. const start = source.indexOf(openingTag)
  498. const end = source.indexOf('</script>', start + openingTag.length)
  499. if (start < 0 || end < 0) throw new Error(`missing script block: ${filePath}`)
  500. return source.slice(start + openingTag.length, end)
  501. }
  502. async function loadVueComponent(filePath, { http = {}, uni = {}, wx = {}, apiRules = rules } = {}) {
  503. const context = vm.createContext({
  504. uni,
  505. wx,
  506. Date,
  507. Promise,
  508. String,
  509. Number,
  510. Boolean,
  511. Object,
  512. Array,
  513. setInterval,
  514. clearInterval
  515. })
  516. const vueStub = new vm.SyntheticModule(['default'], function () {
  517. this.setExport('default', {})
  518. }, { context, identifier: 'test:vue-component' })
  519. const rulesStub = new vm.SyntheticModule(['default'], function () {
  520. this.setExport('default', apiRules)
  521. }, { context, identifier: 'test:team-background-rules' })
  522. const httpNames = [
  523. 'createTeamBackgroundSession',
  524. 'disableTeamBackgroundFile',
  525. 'downloadTeamBackgroundFile',
  526. 'listTeamBackgroundFiles',
  527. 'uploadTeamBackgroundFile'
  528. ]
  529. const httpStub = new vm.SyntheticModule(httpNames, function () {
  530. for (const name of httpNames) {
  531. this.setExport(name, http[name] || (() => Promise.resolve()))
  532. }
  533. }, { context, identifier: 'test:team-background-http' })
  534. const componentModule = new vm.SourceTextModule(readSfcScript(filePath), {
  535. context,
  536. identifier: pathToFileURL(filePath).href
  537. })
  538. await componentModule.link(specifier => {
  539. if (specifier === '@/http/teamBackgroundFile.js') return httpStub
  540. if (specifier === '@/utils/teamBackgroundFile.js') return rulesStub
  541. if (specifier.endsWith('.vue')) return vueStub
  542. throw new Error(`unexpected component import: ${specifier}`)
  543. })
  544. await componentModule.evaluate()
  545. return componentModule.namespace.default
  546. }
  547. function instantiateComponent(component, overrides = {}) {
  548. const base = Object.assign({ initialCount: 0 }, overrides)
  549. const data = typeof component.data === 'function' ? component.data.call(base) : {}
  550. const instance = Object.assign(base, data, overrides)
  551. for (const [name, method] of Object.entries(component.methods || {})) {
  552. instance[name] = method.bind(instance)
  553. }
  554. return instance
  555. }
  556. function createDeferred() {
  557. let resolve
  558. let reject
  559. const promise = new Promise((resolvePromise, rejectPromise) => {
  560. resolve = resolvePromise
  561. reject = rejectPromise
  562. })
  563. return { promise, resolve, reject }
  564. }
  565. const backgroundComponentPath = path.join(__dirname, '../components/CusTeamBackgroundFiles/index.vue')
  566. const teamFillComponentPath = path.join(__dirname, '../components/CusTeamInfoFill/index.vue')
  567. const createTeamPagePath = path.join(__dirname, '../pagesPublish/fillTeamInfo.vue')
  568. const createListComponentPath = path.join(__dirname, '../pagesHome/components/createList.vue')
  569. const teamEditPagePath = path.join(__dirname, '../pagesMy/teamEdit.vue')
  570. const headerComponentPath = path.join(__dirname, '../components/CusHeader/index.vue')
  571. async function testDeferredPcSelectionSurvivesSessionFailure() {
  572. const component = await loadVueComponent(backgroundComponentPath, {
  573. http: {
  574. createTeamBackgroundSession: () => Promise.reject(new Error('session unavailable'))
  575. }
  576. })
  577. const instance = instantiateComponent(component, {
  578. teamId: '',
  579. editable: true,
  580. boundTeamId: 12,
  581. pcUploadDeferred: true,
  582. $showToast() {},
  583. $emit() {}
  584. })
  585. await assert.rejects(instance.waitForDeferredPcUpload(12), /session unavailable/)
  586. assert.equal(instance.pcUploadDeferred, true)
  587. assert.equal(instance.pcDialogVisible, false)
  588. assert.equal(instance.deferredPcResolve, null)
  589. }
  590. async function testSessionRefreshFailureKeepsOldTimer() {
  591. const component = await loadVueComponent(backgroundComponentPath, {
  592. http: {
  593. createTeamBackgroundSession: () => Promise.reject(new Error('refresh failed'))
  594. }
  595. })
  596. const instance = instantiateComponent(component, {
  597. teamId: '', editable: true, boundTeamId: 12, $showToast() {}, $emit() {}
  598. })
  599. const oldSession = { code: '111111', expiresAt: '2099-01-01 00:00:00' }
  600. const oldTimer = setInterval(() => {}, 60 * 1000)
  601. instance.session = oldSession
  602. instance.sessionTimer = oldTimer
  603. try {
  604. await assert.rejects(instance.openPcSession(12), /refresh failed/)
  605. assert.equal(instance.session, oldSession)
  606. assert.equal(instance.sessionTimer, oldTimer)
  607. } finally {
  608. clearInterval(oldTimer)
  609. instance.sessionTimer = null
  610. }
  611. }
  612. async function testDeferredPcFinishWaitsForListRefresh() {
  613. const component = await loadVueComponent(backgroundComponentPath, {
  614. http: {
  615. createTeamBackgroundSession: () => Promise.resolve({
  616. code: '123456',
  617. uploadUrl: 'https://upload.example/',
  618. expiresAt: '2099-01-01 00:00:00'
  619. }),
  620. listTeamBackgroundFiles: () => Promise.reject(new Error('list refresh failed'))
  621. }
  622. })
  623. const instance = instantiateComponent(component, {
  624. teamId: '', editable: true, boundTeamId: 12, pcUploadDeferred: true,
  625. $showToast() {}, $emit() {}
  626. })
  627. const waiting = instance.waitForDeferredPcUpload(12)
  628. await new Promise(resolve => setImmediate(resolve))
  629. assert.equal(typeof instance.deferredPcResolve, 'function')
  630. await instance.finishDeferredPcUpload()
  631. await assert.rejects(waiting, /list refresh failed/)
  632. }
  633. async function testTeamContextResetAndUploadIsolation() {
  634. const uploads = []
  635. const component = await loadVueComponent(backgroundComponentPath, {
  636. http: {
  637. uploadTeamBackgroundFile(teamId, filePath, onProgress) {
  638. const deferred = createDeferred()
  639. uploads.push({ teamId, filePath, onProgress, deferred })
  640. return deferred.promise
  641. },
  642. listTeamBackgroundFiles: () => Promise.resolve([])
  643. }
  644. })
  645. let resolverCalls = 0
  646. const instance = instantiateComponent(component, {
  647. teamId: '',
  648. editable: true,
  649. $showToast() {},
  650. $emit() {}
  651. })
  652. instance.resetTeamContext(1)
  653. const oldItem = rules.createBackgroundQueueItem(
  654. { name: 'old.pdf', path: '/tmp/old.pdf', size: 1 }, 'old'
  655. )
  656. instance.queue.push(oldItem)
  657. instance.deferredPcResolve = () => { resolverCalls += 1 }
  658. const oldFlush = instance.flushPendingFiles(1)
  659. await new Promise(resolve => setImmediate(resolve))
  660. assert.equal(uploads.length, 1)
  661. instance.files = [{ id: 11, fileName: 'old.pdf' }]
  662. instance.count = 1
  663. instance.filesReady = true
  664. instance.session = { code: '111111' }
  665. instance.pcDialogVisible = true
  666. instance.textPreview = rules.createTextPreviewState({ fileName: 'old.txt', content: 'old' })
  667. instance.resetTeamContext(2)
  668. assert.equal(instance.boundTeamId, 2)
  669. assert.equal(instance.files.length, 0)
  670. assert.equal(instance.count, 0)
  671. assert.equal(instance.queue.length, 0)
  672. assert.equal(instance.filesReady, false)
  673. assert.equal(instance.session, null)
  674. assert.equal(instance.pcDialogVisible, false)
  675. assert.equal(instance.textPreview.content, '')
  676. assert.equal(resolverCalls, 1)
  677. const newItem = rules.createBackgroundQueueItem(
  678. { name: 'new.pdf', path: '/tmp/new.pdf', size: 1 }, 'new'
  679. )
  680. instance.queue.push(newItem)
  681. const newFlush = instance.flushPendingFiles(2)
  682. await new Promise(resolve => setImmediate(resolve))
  683. assert.equal(uploads.length, 2, 'the new team must not reuse the old upload promise')
  684. assert.deepEqual(uploads.map(item => item.teamId), [1, 2])
  685. uploads[0].onProgress(75)
  686. assert.equal(oldItem.progress, 0, 'stale upload progress must not mutate the old item after reset')
  687. uploads[0].deferred.resolve({ id: 101 })
  688. await oldFlush
  689. assert.equal(newItem.status, 'uploading')
  690. uploads[1].deferred.resolve({ id: 202 })
  691. await newFlush
  692. assert.equal(newItem.status, 'success')
  693. }
  694. async function testFileActionsWaitForCurrentListAndDocumentContext() {
  695. const listDeferred = createDeferred()
  696. const downloadDeferred = createDeferred()
  697. const opened = []
  698. const toasts = []
  699. let downloadCalls = 0
  700. const wx = {
  701. openDocument(options) {
  702. opened.push(options.filePath)
  703. setImmediate(() => options.success())
  704. }
  705. }
  706. const component = await loadVueComponent(backgroundComponentPath, {
  707. wx,
  708. http: {
  709. listTeamBackgroundFiles: () => listDeferred.promise,
  710. downloadTeamBackgroundFile: () => {
  711. downloadCalls += 1
  712. return downloadDeferred.promise
  713. }
  714. }
  715. })
  716. const instance = instantiateComponent(component, {
  717. teamId: '',
  718. editable: true,
  719. $showToast(message) { toasts.push(message) },
  720. $emit() {}
  721. })
  722. instance.resetTeamContext(3)
  723. const oldFile = { id: 31, fileName: 'old.pdf' }
  724. instance.files = [oldFile]
  725. instance.filesReady = true
  726. const refresh = instance.refreshFiles(3)
  727. await instance.previewFile(oldFile)
  728. assert.equal(downloadCalls, 0, 'old file ids must be disabled while the current list is loading')
  729. listDeferred.resolve([oldFile])
  730. await refresh
  731. const latePreview = instance.previewFile(oldFile)
  732. assert.equal(downloadCalls, 1)
  733. instance.resetTeamContext(4)
  734. downloadDeferred.resolve({ mode: 'document', fileName: 'old.pdf', filePath: '/tmp/old.pdf' })
  735. await latePreview
  736. assert.deepEqual(opened, [], 'a stale download must not open a native document')
  737. const validComponent = await loadVueComponent(backgroundComponentPath, {
  738. wx,
  739. http: {
  740. downloadTeamBackgroundFile: () => Promise.resolve({
  741. mode: 'document', fileName: 'current.pdf', filePath: '/tmp/current.pdf'
  742. })
  743. }
  744. })
  745. const validInstance = instantiateComponent(validComponent, {
  746. teamId: '', editable: true, $showToast(message) { toasts.push(message) }, $emit() {}
  747. })
  748. validInstance.resetTeamContext(5)
  749. const currentFile = { id: 51, fileName: 'current.pdf' }
  750. validInstance.files = [currentFile]
  751. validInstance.filesReady = true
  752. await validInstance.previewFile(currentFile)
  753. assert.deepEqual(opened, ['/tmp/current.pdf'])
  754. const failingComponent = await loadVueComponent(backgroundComponentPath, {
  755. wx: {
  756. openDocument(options) { setImmediate(() => options.fail(new Error('open failed'))) }
  757. },
  758. http: {
  759. downloadTeamBackgroundFile: () => Promise.resolve({
  760. mode: 'document', fileName: 'broken.pdf', filePath: '/tmp/broken.pdf'
  761. })
  762. }
  763. })
  764. const failingInstance = instantiateComponent(failingComponent, {
  765. teamId: '', editable: true, $showToast(message) { toasts.push(message) }, $emit() {}
  766. })
  767. failingInstance.resetTeamContext(6)
  768. const brokenFile = { id: 61, fileName: 'broken.pdf' }
  769. failingInstance.files = [brokenFile]
  770. failingInstance.filesReady = true
  771. await failingInstance.previewFile(brokenFile)
  772. assert.ok(toasts.some(message => String(message).includes('open failed')))
  773. }
  774. async function testTeamFillFlushesBeforeEmitAndLocks() {
  775. const component = await loadVueComponent(teamFillComponentPath)
  776. const flushDeferred = createDeferred()
  777. let flushCalls = 0
  778. let emitCalls = 0
  779. const instance = instantiateComponent(component, {
  780. teamId: 12,
  781. qtype: false,
  782. $props: { qtype: false },
  783. $showToast() {},
  784. $emit() { emitCalls += 1 },
  785. $refs: {
  786. backgroundRef: {
  787. flushPendingFiles() { flushCalls += 1; return flushDeferred.promise },
  788. hasUnresolvedFiles() { return false }
  789. }
  790. }
  791. })
  792. instance.teamInfo = {
  793. teamName: '团队', enterpriseName: '公司', enterpriseWebsite: '无',
  794. districtId: 1, industryId: 2, functionIds: [], orgIds: []
  795. }
  796. const first = instance.handleConfirm()
  797. const second = instance.handleConfirm()
  798. assert.equal(flushCalls, 1)
  799. assert.equal(emitCalls, 0)
  800. flushDeferred.resolve({ success: 1, failed: 0 })
  801. await Promise.all([first, second])
  802. assert.equal(emitCalls, 1)
  803. }
  804. async function testCreatedTeamResumeAndSubmitLock() {
  805. const component = await loadVueComponent(createTeamPagePath)
  806. let teamPostCalls = 0
  807. let flushCalls = 0
  808. const toasts = []
  809. const instance = instantiateComponent(component, {
  810. $api: {
  811. post(url) {
  812. assert.equal(url, '/core/user/team')
  813. teamPostCalls += 1
  814. return Promise.resolve({ data: { code: 0, data: { teamId: 88 } } })
  815. }
  816. },
  817. $showToast(message) { toasts.push(message) },
  818. $showModal: () => Promise.resolve(),
  819. $refs: {
  820. teamRef: {
  821. flushBackgroundFiles() {
  822. flushCalls += 1
  823. if (flushCalls === 1) return Promise.reject(new Error('列表刷新失败'))
  824. return Promise.resolve({ success: 0, failed: 0 })
  825. },
  826. hasUnresolvedBackgroundFiles: () => false,
  827. hasDeferredPcUpload: () => false
  828. }
  829. }
  830. })
  831. let continuations = 0
  832. instance.continueAfterTeamCreated = async teamId => {
  833. assert.equal(teamId, 88)
  834. continuations += 1
  835. }
  836. await instance.handleConfirm({ teamName: '团队' })
  837. assert.equal(teamPostCalls, 1)
  838. assert.equal(instance.creationState.createdTeamId, 88)
  839. assert.ok(toasts.some(message => String(message).includes('背景资料')))
  840. await instance.handleConfirm({ teamName: '团队' })
  841. assert.equal(teamPostCalls, 1, 'retry after a later-stage failure must not POST a second team')
  842. assert.equal(continuations, 1)
  843. const lockedPost = createDeferred()
  844. const lockedInstance = instantiateComponent(component, {
  845. $api: { post: () => { teamPostCalls += 1; return lockedPost.promise } },
  846. $showToast() {},
  847. $showModal: () => Promise.resolve(),
  848. $refs: { teamRef: {} }
  849. })
  850. const lockedFirst = lockedInstance.handleConfirm({ teamName: '团队' })
  851. const callsAfterFirst = teamPostCalls
  852. const lockedSecond = lockedInstance.handleConfirm({ teamName: '团队' })
  853. assert.equal(teamPostCalls, callsAfterFirst, 'concurrent submission must be ignored')
  854. lockedPost.resolve({ data: { code: 1, msg: '保存接口失败' } })
  855. await Promise.all([lockedFirst, lockedSecond])
  856. }
  857. async function testCloseAndBackRequirePendingUploadConfirmation() {
  858. let modalOptions
  859. let navigateBackCalls = 0
  860. const uni = {
  861. showModal(options) { modalOptions = options },
  862. navigateBack() { navigateBackCalls += 1 }
  863. }
  864. const createList = await loadVueComponent(createListComponentPath, { uni })
  865. const createListInstance = instantiateComponent(createList, {
  866. $imgBase: 'https://image.example/',
  867. teamInfoShow: true,
  868. $refs: {
  869. teamRef: {
  870. hasUnresolvedBackgroundFiles: () => true,
  871. isBackgroundUploadActive: () => false
  872. }
  873. }
  874. })
  875. createListInstance.requestCloseTeamInfo()
  876. assert.equal(createListInstance.teamInfoShow, true)
  877. modalOptions.success({ confirm: false })
  878. assert.equal(createListInstance.teamInfoShow, true)
  879. createListInstance.requestCloseTeamInfo()
  880. modalOptions.success({ confirm: true })
  881. assert.equal(createListInstance.teamInfoShow, false)
  882. const teamEdit = await loadVueComponent(teamEditPagePath, { uni })
  883. const teamEditInstance = instantiateComponent(teamEdit, {
  884. $refs: {
  885. teamRef: {
  886. hasUnresolvedBackgroundFiles: () => true,
  887. isBackgroundUploadActive: () => true
  888. }
  889. }
  890. })
  891. teamEditInstance.requestBack()
  892. assert.equal(navigateBackCalls, 0)
  893. modalOptions.success({ confirm: false })
  894. assert.equal(navigateBackCalls, 0)
  895. teamEditInstance.requestBack()
  896. modalOptions.success({ confirm: true })
  897. assert.equal(navigateBackCalls, 1)
  898. let headerBackEvents = 0
  899. const header = await loadVueComponent(headerComponentPath, { uni })
  900. const headerInstance = instantiateComponent(header, {
  901. interceptBack: true,
  902. $emit(name) { if (name === 'back') headerBackEvents += 1 }
  903. })
  904. headerInstance.toBack('')
  905. assert.equal(headerBackEvents, 1)
  906. assert.equal(navigateBackCalls, 1, 'intercepted header back must not navigate directly')
  907. }
  908. async function main() {
  909. const loadedTransport = await loadTransport()
  910. assert.equal(typeof loadedTransport.downloadTeamBackgroundFile, 'function')
  911. await testUploadTransport()
  912. await testApiTransport()
  913. await testDownloadTransport()
  914. await testDeferredPcSelectionSurvivesSessionFailure()
  915. await testSessionRefreshFailureKeepsOldTimer()
  916. await testDeferredPcFinishWaitsForListRefresh()
  917. await testTeamContextResetAndUploadIsolation()
  918. await testFileActionsWaitForCurrentListAndDocumentContext()
  919. await testTeamFillFlushesBeforeEmitAndLocks()
  920. await testCreatedTeamResumeAndSubmitLock()
  921. await testCloseAndBackRequirePendingUploadConfirmation()
  922. console.log('team background transport: PASS')
  923. console.log('team background component behavior: PASS')
  924. }
  925. main().catch(error => {
  926. console.error(error)
  927. process.exitCode = 1
  928. })