teamBackgroundFile.test.js 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734
  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. const backgroundSurveyRules = require('../utils/backgroundSurvey')
  8. const surveyQuestion = {
  9. detailId: 101,
  10. options: [
  11. { code:'A', text:'固定选项', customAllowed:false },
  12. { code:'F', text:'其他', customAllowed:true }
  13. ]
  14. }
  15. assert.equal(backgroundSurveyRules.validateBackgroundAnswer(surveyQuestion, '', ''), '请选择当前题目的答案')
  16. assert.equal(backgroundSurveyRules.validateBackgroundAnswer(surveyQuestion, 'F', ''), '请填写其他选项的具体内容')
  17. assert.equal(backgroundSurveyRules.validateBackgroundAnswer(surveyQuestion, 'A', '忽略内容'), '')
  18. assert.deepEqual(
  19. backgroundSurveyRules.createBackgroundAnswerPayload(surveyQuestion, 'A', '忽略内容'),
  20. { detailId:101, optionCode:'A', customText:'' }
  21. )
  22. assert.deepEqual(
  23. backgroundSurveyRules.createBackgroundAnswerPayload(surveyQuestion, 'F', ' 自定义答案 '),
  24. { detailId:101, optionCode:'F', customText:'自定义答案' }
  25. )
  26. assert.equal(
  27. backgroundSurveyRules.backgroundIntroUrl({ teamQuestionnaireId:12, title:'团队 A' }),
  28. '/pagesPublish/backgroundSurveyIntro?stage=background&teamQuestionnaireId=12&title=%E5%9B%A2%E9%98%9F%20A'
  29. )
  30. assert.equal(rules.validateEnterpriseWebsite(' 无 '), '')
  31. assert.equal(rules.validateEnterpriseWebsite('https://example.com'), '')
  32. assert.match(rules.validateEnterpriseWebsite('ftp://example.com'), /http/)
  33. const websiteWithLength = length => `https://example.com/${'a'.repeat(length - 20)}`
  34. assert.equal(rules.validateEnterpriseWebsite(websiteWithLength(499)), '')
  35. assert.equal(rules.validateEnterpriseWebsite(websiteWithLength(500)), '')
  36. assert.match(rules.validateEnterpriseWebsite(websiteWithLength(501)), /500/)
  37. assert.equal(rules.validateBackgroundFile({ name: 'brief.PDF', size: 1 }), '')
  38. assert.match(rules.validateBackgroundFile({ name: 'brief.zip', size: 1 }), /类型/)
  39. assert.match(rules.validateBackgroundFile({ name: 'brief.pdf', size: 50 * 1024 * 1024 + 1 }), /50MB/)
  40. assert.equal(rules.validateBackgroundFile({ name: 'brief.pdf', size: 50 * 1024 * 1024 }), '')
  41. assert.equal(rules.validateBackgroundFile({ name: `${'a'.repeat(251)}.pdf`, size: 1 }), '')
  42. assert.match(rules.validateBackgroundFile({ name: `${'a'.repeat(252)}.pdf`, size: 1 }), /255/)
  43. assert.match(rules.validateBackgroundFile({ name: 'empty.pdf', size: 0 }), /非空/)
  44. const backendExpiry = '2026-07-21 15:30:00'
  45. const explicitOffsetExpiry = '2026-07-21T15:30:00+08:00'
  46. const utcExpiry = '2026-07-21T07:30:00.250Z'
  47. assert.equal(rules.parseSessionExpiresAt(backendExpiry), Date.UTC(2026, 6, 21, 7, 30, 0))
  48. assert.equal(rules.parseSessionExpiresAt(explicitOffsetExpiry), Date.UTC(2026, 6, 21, 7, 30, 0))
  49. assert.equal(rules.parseSessionExpiresAt(utcExpiry), Date.UTC(2026, 6, 21, 7, 30, 0, 250))
  50. assert.equal(rules.getSessionRemainingSeconds(backendExpiry, Date.UTC(2026, 6, 21, 7, 29, 59, 1)), 1)
  51. assert.equal(rules.getSessionRemainingSeconds(backendExpiry, Date.UTC(2026, 6, 21, 7, 30, 1)), 0)
  52. assert.equal(rules.getSessionRemainingSeconds('2026-07-21T15:30:00', 0), 0)
  53. assert.equal(rules.getSessionRemainingSeconds('not-a-date', 0), 0)
  54. const validQueueItem = rules.createBackgroundQueueItem(
  55. { name: 'brief.pdf', path: '/tmp/brief.pdf', size: 1 },
  56. 'valid-file'
  57. )
  58. const invalidQueueItem = rules.createBackgroundQueueItem(
  59. { name: 'archive.zip', path: '/tmp/archive.zip', size: 1 },
  60. 'invalid-file'
  61. )
  62. assert.equal(validQueueItem.status, 'pending')
  63. assert.equal(validQueueItem.progress, 0)
  64. assert.equal(validQueueItem.validationError, '')
  65. assert.equal(invalidQueueItem.status, 'failed')
  66. assert.match(invalidQueueItem.validationError, /类型/)
  67. validQueueItem.status = 'success'
  68. const uploadFailure = {
  69. id: 'upload-failure',
  70. status: 'failed',
  71. validationError: '',
  72. error: '上传失败'
  73. }
  74. assert.deepEqual(
  75. rules.getUploadableBackgroundFiles([validQueueItem, invalidQueueItem, uploadFailure]),
  76. [uploadFailure]
  77. )
  78. const originalTeam = {
  79. id: 12,
  80. teamName: '研发团队',
  81. enterpriseWebsite: ' https://example.com/team ',
  82. coachId: 3,
  83. uploaderId: 4,
  84. source: 'WECHAT'
  85. }
  86. const teamSubmitPayload = rules.createTeamSubmitPayload(originalTeam)
  87. assert.equal(teamSubmitPayload.id, 12)
  88. assert.equal(teamSubmitPayload.enterpriseWebsite, 'https://example.com/team')
  89. assert.equal(Object.hasOwn(teamSubmitPayload, 'coachId'), false)
  90. assert.equal(Object.hasOwn(teamSubmitPayload, 'uploaderId'), false)
  91. assert.equal(Object.hasOwn(teamSubmitPayload, 'source'), false)
  92. assert.equal(originalTeam.coachId, 3, 'payload creation must not mutate the displayed detail')
  93. const textPreviewState = rules.createTextPreviewState({
  94. mode: 'text',
  95. fileName: '背景说明.md',
  96. content: '# 团队背景',
  97. truncated: true
  98. }, 'fallback.md')
  99. assert.deepEqual(textPreviewState, {
  100. visible: true,
  101. fileName: '背景说明.md',
  102. content: '# 团队背景',
  103. truncated: true
  104. })
  105. assert.deepEqual(rules.createEmptyTextPreviewState(), {
  106. visible: false,
  107. fileName: '',
  108. content: '',
  109. truncated: false
  110. })
  111. assert.notEqual(rules.createEmptyTextPreviewState(), rules.createEmptyTextPreviewState())
  112. const creationState = rules.createTeamCreationState()
  113. assert.deepEqual(creationState, {
  114. createdTeamId: '',
  115. submitting: false,
  116. stage: 'idle',
  117. completed: false,
  118. teamPayloadSnapshot: '',
  119. backgroundSurveyId: '',
  120. backgroundSurveyStatus: 'DRAFT',
  121. teamQuestionnaireId: '',
  122. publishResult: null,
  123. resumeStage: ''
  124. })
  125. const firstPayloadSnapshot = rules.createTeamPayloadSnapshot({
  126. teamName: '团队', enterpriseWebsite: ' https://example.com ', coachId: 7
  127. })
  128. const equivalentPayloadSnapshot = rules.createTeamPayloadSnapshot({
  129. enterpriseWebsite: 'https://example.com', teamName: '团队'
  130. })
  131. assert.equal(firstPayloadSnapshot, equivalentPayloadSnapshot)
  132. assert.notEqual(firstPayloadSnapshot, rules.createTeamPayloadSnapshot({
  133. teamName: '团队', enterpriseWebsite: 'https://changed.example.com'
  134. }))
  135. assert.equal(rules.beginTeamSubmission(creationState), true)
  136. assert.equal(rules.beginTeamSubmission(creationState), false, 'a second submission must be locked')
  137. assert.equal(rules.rememberCreatedTeam(creationState, 88), 88)
  138. assert.equal(rules.rememberCreatedTeam(creationState, 99), 88, 'the first server team id must be retained')
  139. rules.setTeamSubmissionStage(creationState, 'upload')
  140. assert.equal(creationState.stage, 'upload')
  141. rules.finishTeamSubmission(creationState)
  142. assert.equal(creationState.submitting, false)
  143. assert.equal(rules.beginTeamSubmission(creationState), true, 'a failed later stage can resume')
  144. rules.finishTeamSubmission(creationState, true)
  145. assert.equal(creationState.completed, true)
  146. assert.equal(rules.beginTeamSubmission(creationState), false, 'a completed flow cannot be scheduled twice')
  147. assert.equal(rules.hasUnresolvedBackgroundFiles([
  148. { status: 'success', validationError: '' },
  149. { status: 'failed', validationError: '文件类型不支持' }
  150. ]), false)
  151. for (const item of [
  152. { status: 'pending', validationError: '' },
  153. { status: 'uploading', validationError: '' },
  154. { status: 'failed', validationError: '' }
  155. ]) {
  156. assert.equal(rules.hasUnresolvedBackgroundFiles([item]), true)
  157. }
  158. console.log('team background rules: PASS')
  159. const transportPath = path.join(__dirname, '../http/teamBackgroundFile.js')
  160. const httpInterfacePath = path.join(__dirname, '../http/interface.js')
  161. const httpIndexPath = path.join(__dirname, '../http/index.js')
  162. async function loadTransport({ api = {}, uni = {}, wx = {} } = {}) {
  163. const source = fs.readFileSync(transportPath, 'utf8')
  164. const context = vm.createContext({ uni, wx })
  165. const baseApiModule = new vm.SyntheticModule(['BaseApi'], function () {
  166. this.setExport('BaseApi', 'https://api.example.test/app')
  167. }, { context, identifier: 'test:baseApi' })
  168. const apiModule = new vm.SyntheticModule(['default'], function () {
  169. this.setExport('default', api)
  170. }, { context, identifier: 'test:api' })
  171. const transportModule = new vm.SourceTextModule(source, {
  172. context,
  173. identifier: pathToFileURL(transportPath).href
  174. })
  175. await transportModule.link(specifier => {
  176. if (specifier === './baseApi.js') return baseApiModule
  177. if (specifier === './index.js') return apiModule
  178. throw new Error(`unexpected transport import: ${specifier}`)
  179. })
  180. await transportModule.evaluate()
  181. return transportModule.namespace
  182. }
  183. async function loadHttpApi(uni) {
  184. const context = vm.createContext({
  185. uni,
  186. process: { env: { NODE_ENV: 'test' } }
  187. })
  188. const baseApiModule = new vm.SyntheticModule(['BaseApi'], function () {
  189. this.setExport('BaseApi', 'https://api.example.test/app')
  190. }, { context, identifier: 'test:httpBaseApi' })
  191. const interfaceModule = new vm.SourceTextModule(
  192. fs.readFileSync(httpInterfacePath, 'utf8'),
  193. { context, identifier: pathToFileURL(httpInterfacePath).href }
  194. )
  195. const indexModule = new vm.SourceTextModule(
  196. fs.readFileSync(httpIndexPath, 'utf8'),
  197. { context, identifier: pathToFileURL(httpIndexPath).href }
  198. )
  199. await indexModule.link((specifier, referencingModule) => {
  200. if (referencingModule === indexModule && specifier === './interface') return interfaceModule
  201. if (referencingModule === interfaceModule && specifier === './baseApi.js') return baseApiModule
  202. throw new Error(`unexpected http import: ${specifier}`)
  203. })
  204. await indexModule.evaluate()
  205. return indexModule.namespace.default
  206. }
  207. async function testConcurrentSilentRequestDoesNotStrandLoadingMask() {
  208. const requests = []
  209. let shown = 0
  210. let hidden = 0
  211. const api = await loadHttpApi({
  212. showLoading() { shown += 1 },
  213. hideLoading() { hidden += 1 },
  214. getStorageSync() { return '' },
  215. request(options) { requests.push(options) }
  216. })
  217. const visibleRequest = api.get('/visible', {}, true)
  218. const silentRequest = api.get('/silent', {}, false)
  219. assert.equal(shown, 1)
  220. assert.equal(hidden, 0)
  221. assert.equal(requests.length, 2)
  222. requests[1].complete({ statusCode: 200, data: { code: 0, data: [] } })
  223. await silentRequest
  224. assert.equal(hidden, 0, 'a silent request must not hide another request loading mask')
  225. requests[0].complete({ statusCode: 200, data: { code: 0, data: {} } })
  226. await visibleRequest
  227. assert.equal(hidden, 1, 'the request that displayed loading must always hide it on completion')
  228. }
  229. function assertNoIdentityFields(value) {
  230. if (!value || typeof value !== 'object') return
  231. for (const [key, child] of Object.entries(value)) {
  232. assert.ok(!['source', 'coachId', 'uploaderId'].includes(key), `unexpected identity field: ${key}`)
  233. assertNoIdentityFields(child)
  234. }
  235. }
  236. async function createUploadHarness(response, progress = 37) {
  237. let request
  238. const progressValues = []
  239. const uni = {
  240. getStorageSync(key) {
  241. assert.equal(key, 'token')
  242. return 'token-123'
  243. },
  244. uploadFile(options) {
  245. request = options
  246. return {
  247. onProgressUpdate(callback) {
  248. callback({ progress })
  249. setImmediate(() => options.success(response))
  250. }
  251. }
  252. }
  253. }
  254. const transport = await loadTransport({ uni })
  255. return {
  256. request: () => request,
  257. progressValues,
  258. promise: transport.uploadTeamBackgroundFile(12, '/tmp/brief.pdf', value => progressValues.push(value))
  259. }
  260. }
  261. async function testUploadTransport() {
  262. const stringHarness = await createUploadHarness({
  263. statusCode: 200,
  264. data: JSON.stringify({ code: 0, data: { id: 7 } })
  265. })
  266. assert.equal((await stringHarness.promise).id, 7)
  267. assert.equal(stringHarness.request().url, 'https://api.example.test/app/core/user/team/12/background-files')
  268. assert.equal(stringHarness.request().filePath, '/tmp/brief.pdf')
  269. assert.equal(stringHarness.request().name, 'file')
  270. assert.equal(stringHarness.request().header.token, 'token-123')
  271. assert.deepEqual(stringHarness.progressValues, [37])
  272. assertNoIdentityFields(stringHarness.request())
  273. const objectHarness = await createUploadHarness({ statusCode: 200, data: { code: 0, data: { id: 8 } } })
  274. assert.equal((await objectHarness.promise).id, 8)
  275. const statusHarness = await createUploadHarness({ statusCode: 500, data: { code: 0 } })
  276. await assert.rejects(statusHarness.promise, /上传失败,请重试/)
  277. const codeHarness = await createUploadHarness({ statusCode: 200, data: { code: 9, msg: '上传业务失败' } })
  278. await assert.rejects(codeHarness.promise, /上传业务失败/)
  279. const invalidJsonHarness = await createUploadHarness({ statusCode: 200, data: '<invalid-json>' })
  280. await assert.rejects(invalidJsonHarness.promise, /上传失败,请重试/)
  281. }
  282. async function testApiTransport() {
  283. const calls = []
  284. const api = {
  285. get(...args) {
  286. calls.push(['get', ...args])
  287. return Promise.resolve({ data: { code: 0, data: [{ id: 1 }] } })
  288. },
  289. del(...args) {
  290. calls.push(['del', ...args])
  291. return Promise.resolve({ data: { code: 0, data: true } })
  292. },
  293. post(...args) {
  294. calls.push(['post', ...args])
  295. return Promise.resolve({ data: { code: 0, data: { sessionId: 'session-1' } } })
  296. }
  297. }
  298. const transport = await loadTransport({ api })
  299. assert.deepEqual(await transport.listTeamBackgroundFiles(12), [{ id: 1 }])
  300. assert.equal(await transport.disableTeamBackgroundFile(12, 34), true)
  301. assert.deepEqual(await transport.createTeamBackgroundSession(12), { sessionId: 'session-1' })
  302. assert.equal(calls[0][0], 'get')
  303. assert.equal(calls[0][1], '/core/user/team/12/background-files')
  304. assert.equal(calls[1][0], 'del')
  305. assert.equal(calls[1][1], '/core/user/team/12/background-files/34')
  306. assert.equal(calls[2][0], 'post')
  307. assert.equal(calls[2][1], '/core/user/team/12/background-upload-session')
  308. for (const call of calls) {
  309. assert.equal(Object.keys(call[2]).length, 0)
  310. assert.equal(call[3], false)
  311. assertNoIdentityFields(call)
  312. }
  313. const failingTransport = await loadTransport({
  314. api: {
  315. get: () => Promise.resolve({ data: { code: 3, msg: '列表业务失败' } })
  316. }
  317. })
  318. await assert.rejects(failingTransport.listTeamBackgroundFiles(12), /列表业务失败/)
  319. assert.equal(transport.uploadTeamBackgroundFile.length, 2)
  320. assert.equal(transport.listTeamBackgroundFiles.length, 1)
  321. assert.equal(transport.disableTeamBackgroundFile.length, 2)
  322. assert.equal(transport.createTeamBackgroundSession.length, 1)
  323. assert.equal(transport.downloadTeamBackgroundFile.length, 2)
  324. }
  325. const TEXT_PREVIEW_LIMIT = 256 * 1024
  326. async function createDownloadHarness({ result, responseHeaders, downloadError, openError,
  327. fileSize, fileContent = '', fileInfoError, readError, withHeadersListener = true } = {}) {
  328. let request
  329. let openRequest
  330. let fileInfoRequest
  331. let readRequest
  332. let fileSystemManagerCalls = 0
  333. const resolvedFileSize = fileSize === undefined
  334. ? Buffer.byteLength(fileContent, 'utf8')
  335. : fileSize
  336. const uni = {
  337. getStorageSync(key) {
  338. assert.equal(key, 'token')
  339. return 'token-456'
  340. },
  341. downloadFile(options) {
  342. request = options
  343. setImmediate(() => {
  344. if (downloadError) options.fail(downloadError)
  345. else options.success(result)
  346. })
  347. if (!withHeadersListener) return {}
  348. return {
  349. onHeadersReceived(callback) {
  350. if (responseHeaders) callback({ header: responseHeaders })
  351. }
  352. }
  353. }
  354. }
  355. const wx = {
  356. openDocument(options) {
  357. openRequest = options
  358. setImmediate(() => {
  359. if (openError) options.fail(openError)
  360. else options.success('opened')
  361. })
  362. },
  363. getFileSystemManager() {
  364. fileSystemManagerCalls += 1
  365. return {
  366. getFileInfo(options) {
  367. fileInfoRequest = options
  368. setImmediate(() => {
  369. if (fileInfoError) options.fail(fileInfoError)
  370. else options.success({ size: resolvedFileSize })
  371. })
  372. },
  373. readFile(options) {
  374. readRequest = options
  375. setImmediate(() => {
  376. if (readError) options.fail(readError)
  377. else options.success({ data: fileContent })
  378. })
  379. }
  380. }
  381. }
  382. }
  383. const transport = await loadTransport({ uni, wx })
  384. return {
  385. request: () => request,
  386. openRequest: () => openRequest,
  387. fileInfoRequest: () => fileInfoRequest,
  388. readRequest: () => readRequest,
  389. fileSystemManagerCalls: () => fileSystemManagerCalls,
  390. promise: transport.downloadTeamBackgroundFile(12, 34)
  391. }
  392. }
  393. async function testDownloadTransport() {
  394. const successHarness = await createDownloadHarness({
  395. result: { statusCode: 200, tempFilePath: '/tmp/download.pdf' },
  396. responseHeaders: {
  397. 'cOnTeNt-TyPe': 'application/pdf',
  398. 'CONTENT-disPOSITION': 'attachment; filename="download.pdf"'
  399. }
  400. })
  401. const documentPreview = await successHarness.promise
  402. assert.equal(documentPreview.mode, 'document')
  403. assert.equal(documentPreview.fileName, 'download.pdf')
  404. assert.equal(documentPreview.filePath, '/tmp/download.pdf')
  405. assert.equal(successHarness.request().url, 'https://api.example.test/app/core/user/team/12/background-files/34/download')
  406. assert.equal(successHarness.request().header.token, 'token-456')
  407. assert.equal(successHarness.openRequest(), undefined, 'transport must not open a document before component context checks')
  408. assertNoIdentityFields(successHarness.request())
  409. const resultHeaderHarness = await createDownloadHarness({
  410. result: {
  411. statusCode: 200,
  412. tempFilePath: '/tmp/result-header.docx',
  413. header: {
  414. 'content-type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  415. 'content-disposition': 'attachment; filename="result-header.docx"'
  416. }
  417. },
  418. withHeadersListener: false
  419. })
  420. const resultHeaderPreview = await resultHeaderHarness.promise
  421. assert.equal(resultHeaderPreview.mode, 'document')
  422. assert.equal(resultHeaderPreview.fileName, 'result-header.docx')
  423. assert.equal(resultHeaderPreview.filePath, '/tmp/result-header.docx')
  424. assert.equal(resultHeaderHarness.openRequest(), undefined)
  425. assert.equal(resultHeaderHarness.fileSystemManagerCalls(), 0)
  426. const txtHarness = await createDownloadHarness({
  427. result: {
  428. statusCode: 200,
  429. tempFilePath: '/tmp/text-preview.txt',
  430. headers: {
  431. 'Content-Type': 'text/plain; charset=UTF-8',
  432. 'Content-Disposition': 'attachment; filename="fallback.txt"; '
  433. + "filename*=UTF-8''%E8%83%8C%E6%99%AF%20%E8%B5%84%E6%96%99.txt"
  434. }
  435. },
  436. fileSize: 5,
  437. fileContent: 'hello',
  438. withHeadersListener: false
  439. })
  440. const txtPreview = await txtHarness.promise
  441. assert.equal(txtHarness.openRequest(), undefined, 'txt must not call openDocument')
  442. assert.equal(txtPreview.mode, 'text')
  443. assert.equal(txtPreview.fileName, '背景 资料.txt')
  444. assert.equal(txtPreview.content, 'hello')
  445. assert.equal(txtPreview.truncated, false)
  446. assert.equal(txtHarness.fileInfoRequest().filePath, '/tmp/text-preview.txt')
  447. assert.equal(txtHarness.readRequest().filePath, '/tmp/text-preview.txt')
  448. assert.equal(txtHarness.readRequest().encoding, 'utf8')
  449. assert.equal(txtHarness.readRequest().position, 0)
  450. assert.equal(txtHarness.readRequest().length, 5)
  451. const emptyTextHarness = await createDownloadHarness({
  452. result: { statusCode: 200, tempFilePath: '/tmp/empty.txt' },
  453. responseHeaders: {
  454. 'Content-Type': 'text/plain',
  455. 'Content-Disposition': 'attachment; filename="empty.txt"'
  456. },
  457. fileSize: 0
  458. })
  459. const emptyTextPreview = await emptyTextHarness.promise
  460. assert.equal(emptyTextPreview.mode, 'text')
  461. assert.equal(emptyTextPreview.fileName, 'empty.txt')
  462. assert.equal(emptyTextPreview.content, '')
  463. assert.equal(emptyTextPreview.truncated, false)
  464. assert.equal(emptyTextHarness.readRequest(), undefined)
  465. const invalidEncodedNameHarness = await createDownloadHarness({
  466. result: { statusCode: 200, tempFilePath: '/tmp/fallback.txt' },
  467. responseHeaders: {
  468. 'Content-Type': 'text/plain',
  469. 'Content-Disposition': 'attachment; filename="fallback.txt"; '
  470. + "filename*=UTF-8''%E0%A4%A"
  471. },
  472. fileSize: 8,
  473. fileContent: 'fallback'
  474. })
  475. const invalidEncodedNamePreview = await invalidEncodedNameHarness.promise
  476. assert.equal(invalidEncodedNamePreview.fileName, 'fallback.txt')
  477. assert.equal(invalidEncodedNamePreview.content, 'fallback')
  478. const mdContent = 'm'.repeat(TEXT_PREVIEW_LIMIT)
  479. const mdHarness = await createDownloadHarness({
  480. result: { statusCode: 200, tempFilePath: '/tmp/guide.md' },
  481. responseHeaders: {
  482. 'Content-Type': 'text/markdown; charset=UTF-8',
  483. 'Content-Disposition': 'attachment; filename="guide.md"'
  484. },
  485. fileSize: 50 * 1024 * 1024,
  486. fileContent: mdContent
  487. })
  488. const mdPreview = await mdHarness.promise
  489. assert.equal(mdHarness.openRequest(), undefined)
  490. assert.equal(mdPreview.mode, 'text')
  491. assert.equal(mdPreview.fileName, 'guide.md')
  492. assert.equal(mdPreview.content.length, TEXT_PREVIEW_LIMIT)
  493. assert.equal(mdPreview.truncated, true)
  494. assert.equal(mdHarness.readRequest().length, TEXT_PREVIEW_LIMIT)
  495. const csvContent = 'c'.repeat(TEXT_PREVIEW_LIMIT)
  496. const csvHarness = await createDownloadHarness({
  497. result: { statusCode: 200, tempFilePath: '/tmp/data.csv' },
  498. responseHeaders: {
  499. 'Content-Type': 'text/csv',
  500. 'Content-Disposition': 'attachment; filename=data.csv'
  501. },
  502. fileSize: TEXT_PREVIEW_LIMIT,
  503. fileContent: csvContent
  504. })
  505. const csvPreview = await csvHarness.promise
  506. assert.equal(csvHarness.openRequest(), undefined)
  507. assert.equal(csvPreview.mode, 'text')
  508. assert.equal(csvPreview.fileName, 'data.csv')
  509. assert.equal(csvPreview.content.length, TEXT_PREVIEW_LIMIT)
  510. assert.equal(csvPreview.truncated, false)
  511. assert.equal(csvHarness.readRequest().length, TEXT_PREVIEW_LIMIT)
  512. const fileInfoFailureHarness = await createDownloadHarness({
  513. result: { statusCode: 200, tempFilePath: '/tmp/file-info-failure.txt' },
  514. responseHeaders: {
  515. 'Content-Type': 'text/plain',
  516. 'Content-Disposition': 'attachment; filename="file-info-failure.txt"'
  517. },
  518. fileInfoError: new Error('getFileInfo failed')
  519. })
  520. await assert.rejects(fileInfoFailureHarness.promise, /文件读取失败,请重试/)
  521. assert.equal(fileInfoFailureHarness.openRequest(), undefined)
  522. assert.equal(fileInfoFailureHarness.readRequest(), undefined)
  523. const readFailureHarness = await createDownloadHarness({
  524. result: { statusCode: 200, tempFilePath: '/tmp/read-failure.md' },
  525. responseHeaders: {
  526. 'Content-Type': 'text/markdown',
  527. 'Content-Disposition': 'attachment; filename="read-failure.md"'
  528. },
  529. fileSize: 10,
  530. readError: new Error('readFile failed')
  531. })
  532. await assert.rejects(readFailureHarness.promise, /文件读取失败,请重试/)
  533. assert.equal(readFailureHarness.openRequest(), undefined)
  534. const unknownExtensionHarness = await createDownloadHarness({
  535. result: { statusCode: 200, tempFilePath: '/tmp/unknown-file' },
  536. responseHeaders: {
  537. 'Content-Type': 'application/octet-stream',
  538. 'Content-Disposition': 'attachment; filename="archive.zip"'
  539. }
  540. })
  541. await assert.rejects(unknownExtensionHarness.promise, /文件类型不支持预览/)
  542. assert.equal(unknownExtensionHarness.openRequest(), undefined)
  543. assert.equal(unknownExtensionHarness.fileSystemManagerCalls(), 0)
  544. const jsonHarness = await createDownloadHarness({
  545. result: { statusCode: 200, tempFilePath: '/tmp/error.json' },
  546. responseHeaders: {
  547. 'Content-Type': 'application/json;charset=UTF-8',
  548. 'Content-Disposition': 'attachment; filename="error.json"'
  549. }
  550. })
  551. await assert.rejects(jsonHarness.promise, /下载失败,请重试/)
  552. assert.equal(jsonHarness.openRequest(), undefined)
  553. const htmlHarness = await createDownloadHarness({
  554. result: { statusCode: 200, tempFilePath: '/tmp/error.html' },
  555. responseHeaders: {
  556. 'content-type': 'text/html; charset=UTF-8',
  557. 'content-disposition': 'attachment; filename="error.html"'
  558. }
  559. })
  560. await assert.rejects(htmlHarness.promise, /下载失败,请重试/)
  561. assert.equal(htmlHarness.openRequest(), undefined)
  562. const missingHeadersHarness = await createDownloadHarness({
  563. result: { statusCode: 200, tempFilePath: '/tmp/unknown.pdf' },
  564. withHeadersListener: false
  565. })
  566. await assert.rejects(missingHeadersHarness.promise, /下载失败,请重试/)
  567. assert.equal(missingHeadersHarness.openRequest(), undefined)
  568. const statusHarness = await createDownloadHarness({ result: { statusCode: 500 } })
  569. await assert.rejects(statusHarness.promise, /下载失败,请重试/)
  570. assert.equal(statusHarness.openRequest(), undefined)
  571. const downloadError = new Error('download network error')
  572. const downloadFailureHarness = await createDownloadHarness({ downloadError })
  573. await assert.rejects(downloadFailureHarness.promise, /download network error/)
  574. const noTransportOpenHarness = await createDownloadHarness({
  575. result: { statusCode: 200, tempFilePath: '/tmp/download.pdf' },
  576. responseHeaders: {
  577. 'Content-Type': 'application/pdf',
  578. 'Content-Disposition': 'attachment; filename="download.pdf"'
  579. },
  580. openError: new Error('must not be reached')
  581. })
  582. assert.equal((await noTransportOpenHarness.promise).mode, 'document')
  583. assert.equal(noTransportOpenHarness.openRequest(), undefined)
  584. }
  585. function readSfcScript(filePath) {
  586. const source = fs.readFileSync(filePath, 'utf8')
  587. const openingTag = '<script>'
  588. const start = source.indexOf(openingTag)
  589. const end = source.indexOf('</script>', start + openingTag.length)
  590. if (start < 0 || end < 0) throw new Error(`missing script block: ${filePath}`)
  591. return source.slice(start + openingTag.length, end)
  592. }
  593. async function loadVueComponent(filePath, {
  594. http = {}, uni = {}, wx = {}, apiRules = rules,
  595. backgroundRules = backgroundSurveyRules, timers = {}
  596. } = {}) {
  597. const context = vm.createContext({
  598. uni,
  599. wx,
  600. Date,
  601. Promise,
  602. String,
  603. Number,
  604. Boolean,
  605. Object,
  606. Array,
  607. setInterval: timers.setInterval || setInterval,
  608. clearInterval: timers.clearInterval || clearInterval,
  609. setTimeout: timers.setTimeout || setTimeout,
  610. clearTimeout: timers.clearTimeout || clearTimeout
  611. })
  612. const vueStub = new vm.SyntheticModule(['default'], function () {
  613. this.setExport('default', {})
  614. }, { context, identifier: 'test:vue-component' })
  615. const rulesStub = new vm.SyntheticModule(['default'], function () {
  616. this.setExport('default', apiRules)
  617. }, { context, identifier: 'test:team-background-rules' })
  618. const backgroundRulesStub = new vm.SyntheticModule(['default'], function () {
  619. this.setExport('default', backgroundRules)
  620. }, { context, identifier: 'test:background-survey-rules' })
  621. const httpNames = [
  622. 'createTeamBackgroundSession',
  623. 'disableTeamBackgroundFile',
  624. 'downloadTeamBackgroundFile',
  625. 'listTeamBackgroundFiles',
  626. 'uploadTeamBackgroundFile'
  627. ]
  628. const httpStub = new vm.SyntheticModule(httpNames, function () {
  629. for (const name of httpNames) {
  630. this.setExport(name, http[name] || (() => Promise.resolve()))
  631. }
  632. }, { context, identifier: 'test:team-background-http' })
  633. const componentModule = new vm.SourceTextModule(readSfcScript(filePath), {
  634. context,
  635. identifier: pathToFileURL(filePath).href
  636. })
  637. await componentModule.link(specifier => {
  638. if (specifier === '@/http/teamBackgroundFile.js') return httpStub
  639. if (specifier === '@/utils/teamBackgroundFile.js') return rulesStub
  640. if (specifier === '@/utils/backgroundSurvey.js') return backgroundRulesStub
  641. if (specifier.endsWith('.vue')) return vueStub
  642. throw new Error(`unexpected component import: ${specifier}`)
  643. })
  644. await componentModule.evaluate()
  645. return componentModule.namespace.default
  646. }
  647. function instantiateComponent(component, overrides = {}) {
  648. const base = Object.assign({ initialCount: 0 }, overrides)
  649. const data = typeof component.data === 'function' ? component.data.call(base) : {}
  650. const instance = Object.assign(base, data, overrides)
  651. for (const [name, method] of Object.entries(component.methods || {})) {
  652. instance[name] = method.bind(instance)
  653. }
  654. return instance
  655. }
  656. function createDeferred() {
  657. let resolve
  658. let reject
  659. const promise = new Promise((resolvePromise, rejectPromise) => {
  660. resolve = resolvePromise
  661. reject = rejectPromise
  662. })
  663. return { promise, resolve, reject }
  664. }
  665. function createFakeTimers() {
  666. let nextId = 1
  667. const pending = new Map()
  668. return {
  669. setTimeout(callback) {
  670. const id = nextId++
  671. pending.set(id, callback)
  672. return id
  673. },
  674. clearTimeout(id) {
  675. pending.delete(id)
  676. },
  677. count() {
  678. return pending.size
  679. },
  680. runNext() {
  681. const entry = pending.entries().next().value
  682. if (!entry) return false
  683. pending.delete(entry[0])
  684. entry[1]()
  685. return true
  686. },
  687. runAll() {
  688. while (this.runNext()) {}
  689. }
  690. }
  691. }
  692. async function flushPromises() {
  693. await Promise.resolve()
  694. await new Promise(resolve => setImmediate(resolve))
  695. }
  696. const backgroundComponentPath = path.join(__dirname, '../components/CusTeamBackgroundFiles/index.vue')
  697. const teamFillComponentPath = path.join(__dirname, '../components/CusTeamInfoFill/index.vue')
  698. const createTeamPagePath = path.join(__dirname, '../pagesPublish/fillTeamInfo.vue')
  699. const createListComponentPath = path.join(__dirname, '../pagesHome/components/createList.vue')
  700. const teamEditPagePath = path.join(__dirname, '../pagesMy/teamEdit.vue')
  701. const headerComponentPath = path.join(__dirname, '../components/CusHeader/index.vue')
  702. const backgroundSurveyPagePath = path.join(__dirname, '../pagesPublish/backgroundSurvey.vue')
  703. const backgroundSurveyIntroPagePath = path.join(__dirname, '../pagesPublish/backgroundSurveyIntro.vue')
  704. async function testBackgroundSurveyPagesLoad() {
  705. const survey = await loadVueComponent(backgroundSurveyPagePath)
  706. const intro = await loadVueComponent(backgroundSurveyIntroPagePath)
  707. assert.equal(typeof survey.methods.loadSurvey, 'function')
  708. assert.equal(typeof survey.methods.saveCurrent, 'function')
  709. assert.equal(typeof survey.methods.next, 'function')
  710. assert.equal(typeof intro.methods.start, 'function')
  711. }
  712. async function testDeferredPcSelectionSurvivesSessionFailure() {
  713. let createCalls = 0
  714. const firstRequest = createDeferred()
  715. const component = await loadVueComponent(backgroundComponentPath, {
  716. http: {
  717. createTeamBackgroundSession() {
  718. createCalls += 1
  719. if (createCalls === 1) return firstRequest.promise
  720. return Promise.resolve({
  721. code: '123456', uploadUrl: 'https://upload.example/retry', expiresAt: '2099-01-01 00:00:00'
  722. })
  723. }
  724. }
  725. })
  726. const instance = instantiateComponent(component, {
  727. teamId: '',
  728. editable: true,
  729. boundTeamId: 12,
  730. pcUploadDeferred: true,
  731. $showToast() {},
  732. $emit() {}
  733. })
  734. const first = instance.waitForDeferredPcUpload(12)
  735. const second = instance.waitForDeferredPcUpload(12)
  736. assert.equal(first, second, 'the whole deferred flow must expose one shared waiter')
  737. assert.equal(createCalls, 1)
  738. firstRequest.reject(new Error('session unavailable'))
  739. const failed = await Promise.allSettled([first, second])
  740. assert.deepEqual(failed.map(result => result.status), ['rejected', 'rejected'])
  741. assert.match(failed[0].reason.message, /session unavailable/)
  742. assert.equal(instance.pcUploadDeferred, true)
  743. assert.equal(instance.pcDialogVisible, false)
  744. assert.equal(instance.deferredPcResolve, null)
  745. assert.equal(instance.deferredPcWaitPromise, null)
  746. const retry = instance.waitForDeferredPcUpload(12)
  747. await flushPromises()
  748. assert.equal(createCalls, 2, 'a failed shared waiter must be retryable')
  749. assert.equal(typeof instance.deferredPcResolve, 'function')
  750. instance.skipDeferredPcUpload()
  751. await retry
  752. }
  753. async function testSessionRefreshFailureKeepsOldTimer() {
  754. const component = await loadVueComponent(backgroundComponentPath, {
  755. http: {
  756. createTeamBackgroundSession: () => Promise.reject(new Error('refresh failed'))
  757. }
  758. })
  759. const instance = instantiateComponent(component, {
  760. teamId: '', editable: true, boundTeamId: 12, $showToast() {}, $emit() {}
  761. })
  762. const oldSession = { code: '111111', expiresAt: '2099-01-01 00:00:00' }
  763. const oldTimer = setInterval(() => {}, 60 * 1000)
  764. instance.session = oldSession
  765. instance.sessionTimer = oldTimer
  766. try {
  767. await assert.rejects(instance.openPcSession(12), /refresh failed/)
  768. assert.equal(instance.session, oldSession)
  769. assert.equal(instance.sessionTimer, oldTimer)
  770. } finally {
  771. clearInterval(oldTimer)
  772. instance.sessionTimer = null
  773. }
  774. }
  775. async function testPcSessionCreationIsSingleFlightPerTeam() {
  776. const firstRequest = createDeferred()
  777. let createCalls = 0
  778. const component = await loadVueComponent(backgroundComponentPath, {
  779. http: {
  780. createTeamBackgroundSession() {
  781. createCalls += 1
  782. return firstRequest.promise
  783. }
  784. }
  785. })
  786. const instance = instantiateComponent(component, {
  787. teamId: '', editable: true, $showToast() {}, $emit() {}
  788. })
  789. instance.resetTeamContext(12)
  790. const first = instance.openPcSession(12)
  791. const second = instance.openPcSession(12)
  792. assert.equal(createCalls, 1, 'same-team callers must share one session POST')
  793. assert.equal(instance.sessionCreating, true)
  794. instance.refreshPcSession()
  795. assert.equal(createCalls, 1, 'disabled regenerate must not create another POST')
  796. instance.resetTeamContext(13)
  797. assert.equal(instance.sessionCreating, false, 'creating state follows the active team')
  798. firstRequest.resolve({
  799. code: '123456', uploadUrl: 'https://upload.example/', expiresAt: '2099-01-01 00:00:00'
  800. })
  801. assert.equal(await first, null)
  802. assert.equal(await second, null)
  803. assert.equal(instance.session, null, 'a stale context must not display the resolved session')
  804. assert.equal(instance.pcDialogVisible, false)
  805. }
  806. async function testPcSessionSingleFlightSurvivesSameTeamReset() {
  807. const request = createDeferred()
  808. let createCalls = 0
  809. const component = await loadVueComponent(backgroundComponentPath, {
  810. http: {
  811. createTeamBackgroundSession() {
  812. createCalls += 1
  813. return request.promise
  814. }
  815. }
  816. })
  817. const instance = instantiateComponent(component, {
  818. teamId: '', editable: true, $showToast() {}, $emit() {}
  819. })
  820. instance.resetTeamContext(21)
  821. const staleCaller = instance.openPcSession(21)
  822. instance.resetTeamContext(21)
  823. assert.equal(instance.sessionCreating, true, 'same-team reset must retain the network lock')
  824. const currentCaller = instance.openPcSession(21)
  825. assert.equal(createCalls, 1, 'same-team reset must reuse the raw network promise')
  826. const rawSession = {
  827. code: '654321', uploadUrl: 'https://upload.example/current', expiresAt: '2099-01-01 00:00:00'
  828. }
  829. request.resolve(rawSession)
  830. assert.equal(await staleCaller, null)
  831. assert.equal(await currentCaller, rawSession)
  832. assert.equal(instance.session, rawSession)
  833. assert.equal(instance.pcDialogVisible, true)
  834. assert.equal(instance.sessionCreating, false)
  835. instance.closePcDialog()
  836. }
  837. async function testPcSessionFailureClearsSingleFlightForRetry() {
  838. let createCalls = 0
  839. const component = await loadVueComponent(backgroundComponentPath, {
  840. http: {
  841. createTeamBackgroundSession() {
  842. createCalls += 1
  843. if (createCalls === 1) return Promise.reject(new Error('session unavailable'))
  844. return Promise.resolve({
  845. code: '222222', uploadUrl: 'https://upload.example/retry', expiresAt: '2099-01-01 00:00:00'
  846. })
  847. }
  848. }
  849. })
  850. const instance = instantiateComponent(component, {
  851. teamId: '', editable: true, $showToast() {}, $emit() {}
  852. })
  853. instance.resetTeamContext(22)
  854. await assert.rejects(instance.openPcSession(22), /session unavailable/)
  855. assert.equal(instance.sessionCreating, false)
  856. const session = await instance.openPcSession(22)
  857. assert.equal(createCalls, 2)
  858. assert.equal(session.code, '222222')
  859. assert.equal(instance.sessionCreating, false)
  860. instance.closePcDialog()
  861. }
  862. async function testDeferredPcFinishWaitsForListRefresh() {
  863. const component = await loadVueComponent(backgroundComponentPath, {
  864. http: {
  865. createTeamBackgroundSession: () => Promise.resolve({
  866. code: '123456',
  867. uploadUrl: 'https://upload.example/',
  868. expiresAt: '2099-01-01 00:00:00'
  869. }),
  870. listTeamBackgroundFiles: () => Promise.reject(new Error('list refresh failed'))
  871. }
  872. })
  873. const instance = instantiateComponent(component, {
  874. teamId: '', editable: true, boundTeamId: 12, pcUploadDeferred: true,
  875. $showToast() {}, $emit() {}
  876. })
  877. const waiting = instance.waitForDeferredPcUpload(12)
  878. await new Promise(resolve => setImmediate(resolve))
  879. assert.equal(typeof instance.deferredPcResolve, 'function')
  880. await instance.finishDeferredPcUpload()
  881. await assert.rejects(waiting, /list refresh failed/)
  882. }
  883. async function testDeferredPcWaiterSingleFlightFinishesAllCallers() {
  884. let createCalls = 0
  885. const sessionRequest = createDeferred()
  886. const component = await loadVueComponent(backgroundComponentPath, {
  887. http: {
  888. createTeamBackgroundSession() {
  889. createCalls += 1
  890. return sessionRequest.promise
  891. },
  892. listTeamBackgroundFiles: () => Promise.resolve([])
  893. }
  894. })
  895. const instance = instantiateComponent(component, {
  896. teamId: '', editable: true, boundTeamId: 12, pcUploadDeferred: true,
  897. $showToast() {}, $emit() {}
  898. })
  899. const first = instance.waitForDeferredPcUpload(12)
  900. const second = instance.waitForDeferredPcUpload(12)
  901. assert.equal(first, second)
  902. assert.equal(createCalls, 1)
  903. let settled = false
  904. first.then(() => { settled = true })
  905. sessionRequest.resolve({
  906. code: '654321', uploadUrl: 'https://upload.example/', expiresAt: '2099-01-01 00:00:00'
  907. })
  908. await flushPromises()
  909. assert.equal(settled, false, 'session creation must not settle the dialog waiter')
  910. await instance.finishDeferredPcUpload()
  911. await Promise.all([first, second])
  912. assert.equal(settled, true)
  913. assert.equal(instance.deferredPcWaitPromise, null)
  914. }
  915. async function testTeamContextResetAndUploadIsolation() {
  916. const uploads = []
  917. const component = await loadVueComponent(backgroundComponentPath, {
  918. http: {
  919. uploadTeamBackgroundFile(teamId, filePath, onProgress) {
  920. const deferred = createDeferred()
  921. uploads.push({ teamId, filePath, onProgress, deferred })
  922. return deferred.promise
  923. },
  924. listTeamBackgroundFiles: () => Promise.resolve([])
  925. }
  926. })
  927. let resolverCalls = 0
  928. const instance = instantiateComponent(component, {
  929. teamId: '',
  930. editable: true,
  931. $showToast() {},
  932. $emit() {}
  933. })
  934. instance.resetTeamContext(1)
  935. const oldItem = rules.createBackgroundQueueItem(
  936. { name: 'old.pdf', path: '/tmp/old.pdf', size: 1 }, 'old'
  937. )
  938. instance.queue.push(oldItem)
  939. instance.deferredPcResolve = () => { resolverCalls += 1 }
  940. const oldFlush = instance.flushPendingFiles(1)
  941. await new Promise(resolve => setImmediate(resolve))
  942. assert.equal(uploads.length, 1)
  943. instance.files = [{ id: 11, fileName: 'old.pdf' }]
  944. instance.count = 1
  945. instance.filesReady = true
  946. instance.session = { code: '111111' }
  947. instance.pcDialogVisible = true
  948. instance.textPreview = rules.createTextPreviewState({ fileName: 'old.txt', content: 'old' })
  949. instance.resetTeamContext(2)
  950. assert.equal(instance.boundTeamId, 2)
  951. assert.equal(instance.files.length, 0)
  952. assert.equal(instance.count, 0)
  953. assert.equal(instance.queue.length, 0)
  954. assert.equal(instance.filesReady, false)
  955. assert.equal(instance.session, null)
  956. assert.equal(instance.pcDialogVisible, false)
  957. assert.equal(instance.textPreview.content, '')
  958. assert.equal(resolverCalls, 1)
  959. const newItem = rules.createBackgroundQueueItem(
  960. { name: 'new.pdf', path: '/tmp/new.pdf', size: 1 }, 'new'
  961. )
  962. instance.queue.push(newItem)
  963. const newFlush = instance.flushPendingFiles(2)
  964. await new Promise(resolve => setImmediate(resolve))
  965. assert.equal(uploads.length, 2, 'the new team must not reuse the old upload promise')
  966. assert.deepEqual(uploads.map(item => item.teamId), [1, 2])
  967. uploads[0].onProgress(75)
  968. assert.equal(oldItem.progress, 0, 'stale upload progress must not mutate the old item after reset')
  969. uploads[0].deferred.resolve({ id: 101 })
  970. await oldFlush
  971. assert.equal(newItem.status, 'uploading')
  972. uploads[1].deferred.resolve({ id: 202 })
  973. await newFlush
  974. assert.equal(newItem.status, 'success')
  975. }
  976. async function testFileActionsWaitForCurrentListAndDocumentContext() {
  977. const listDeferred = createDeferred()
  978. const downloadDeferred = createDeferred()
  979. const opened = []
  980. const toasts = []
  981. let downloadCalls = 0
  982. const wx = {
  983. openDocument(options) {
  984. opened.push(options.filePath)
  985. setImmediate(() => options.success())
  986. }
  987. }
  988. const component = await loadVueComponent(backgroundComponentPath, {
  989. wx,
  990. http: {
  991. listTeamBackgroundFiles: () => listDeferred.promise,
  992. downloadTeamBackgroundFile: () => {
  993. downloadCalls += 1
  994. return downloadDeferred.promise
  995. }
  996. }
  997. })
  998. const instance = instantiateComponent(component, {
  999. teamId: '',
  1000. editable: true,
  1001. $showToast(message) { toasts.push(message) },
  1002. $emit() {}
  1003. })
  1004. instance.resetTeamContext(3)
  1005. const oldFile = { id: 31, fileName: 'old.pdf' }
  1006. instance.files = [oldFile]
  1007. instance.filesReady = true
  1008. const refresh = instance.refreshFiles(3)
  1009. await instance.previewFile(oldFile)
  1010. assert.equal(downloadCalls, 0, 'old file ids must be disabled while the current list is loading')
  1011. listDeferred.resolve([oldFile])
  1012. await refresh
  1013. const latePreview = instance.previewFile(oldFile)
  1014. assert.equal(downloadCalls, 1)
  1015. instance.resetTeamContext(4)
  1016. downloadDeferred.resolve({ mode: 'document', fileName: 'old.pdf', filePath: '/tmp/old.pdf' })
  1017. await latePreview
  1018. assert.deepEqual(opened, [], 'a stale download must not open a native document')
  1019. const validComponent = await loadVueComponent(backgroundComponentPath, {
  1020. wx,
  1021. http: {
  1022. downloadTeamBackgroundFile: () => Promise.resolve({
  1023. mode: 'document', fileName: 'current.pdf', filePath: '/tmp/current.pdf'
  1024. })
  1025. }
  1026. })
  1027. const validInstance = instantiateComponent(validComponent, {
  1028. teamId: '', editable: true, $showToast(message) { toasts.push(message) }, $emit() {}
  1029. })
  1030. validInstance.resetTeamContext(5)
  1031. const currentFile = { id: 51, fileName: 'current.pdf' }
  1032. validInstance.files = [currentFile]
  1033. validInstance.filesReady = true
  1034. await validInstance.previewFile(currentFile)
  1035. assert.deepEqual(opened, ['/tmp/current.pdf'])
  1036. const failingComponent = await loadVueComponent(backgroundComponentPath, {
  1037. wx: {
  1038. openDocument(options) { setImmediate(() => options.fail(new Error('open failed'))) }
  1039. },
  1040. http: {
  1041. downloadTeamBackgroundFile: () => Promise.resolve({
  1042. mode: 'document', fileName: 'broken.pdf', filePath: '/tmp/broken.pdf'
  1043. })
  1044. }
  1045. })
  1046. const failingInstance = instantiateComponent(failingComponent, {
  1047. teamId: '', editable: true, $showToast(message) { toasts.push(message) }, $emit() {}
  1048. })
  1049. failingInstance.resetTeamContext(6)
  1050. const brokenFile = { id: 61, fileName: 'broken.pdf' }
  1051. failingInstance.files = [brokenFile]
  1052. failingInstance.filesReady = true
  1053. await failingInstance.previewFile(brokenFile)
  1054. assert.ok(toasts.some(message => String(message).includes('open failed')))
  1055. }
  1056. async function testUploadedFileUsesDeleteWordingAndRefreshesAfterDelete() {
  1057. let modalOptions
  1058. const disableCalls = []
  1059. const toasts = []
  1060. const component = await loadVueComponent(backgroundComponentPath, {
  1061. uni: {
  1062. showModal(options) {
  1063. modalOptions = options
  1064. }
  1065. },
  1066. http: {
  1067. disableTeamBackgroundFile(teamId, fileId) {
  1068. disableCalls.push([teamId, fileId])
  1069. return Promise.resolve(true)
  1070. },
  1071. listTeamBackgroundFiles() {
  1072. return Promise.resolve([])
  1073. }
  1074. }
  1075. })
  1076. const instance = instantiateComponent(component, {
  1077. teamId: '',
  1078. editable: true,
  1079. $showToast(message) { toasts.push(message) },
  1080. $emit() {}
  1081. })
  1082. instance.resetTeamContext(5)
  1083. const file = { id: 51, fileName: '背景资料.pdf' }
  1084. instance.files = [file]
  1085. instance.filesReady = true
  1086. instance.removeFile(file)
  1087. assert.equal(modalOptions.title, '删除资料')
  1088. assert.match(modalOptions.content, /确定删除/)
  1089. modalOptions.success({ confirm: true })
  1090. await flushPromises()
  1091. assert.deepEqual(disableCalls, [[5, 51]])
  1092. assert.deepEqual(instance.files, [])
  1093. assert.ok(toasts.includes('已删除'))
  1094. const source = fs.readFileSync(backgroundComponentPath, 'utf8')
  1095. assert.match(source, />删除<\/view>/)
  1096. assert.doesNotMatch(source, /停用资料|已停用|停用失败/)
  1097. }
  1098. async function testSameTeamRefreshFailurePreservesExistingFilesAndCount() {
  1099. const listRequest = createDeferred()
  1100. const emittedCounts = []
  1101. const component = await loadVueComponent(backgroundComponentPath, {
  1102. http: { listTeamBackgroundFiles: () => listRequest.promise }
  1103. })
  1104. const instance = instantiateComponent(component, {
  1105. teamId: '', editable: true, $showToast() {},
  1106. $emit(name, value) { if (name === 'countChange') emittedCounts.push(value) }
  1107. })
  1108. instance.resetTeamContext(30)
  1109. const existingFiles = [{ id: 301, fileName: 'existing.pdf' }]
  1110. instance.files = existingFiles
  1111. instance.count = 1
  1112. instance.filesReady = true
  1113. instance.hasLoadedFiles = true
  1114. emittedCounts.length = 0
  1115. const refresh = instance.refreshFiles(30)
  1116. assert.equal(instance.files, existingFiles, 'same-team refresh must retain the visible list while loading')
  1117. assert.equal(instance.count, 1)
  1118. assert.deepEqual(emittedCounts, [], 'same-team refresh must not transiently emit zero')
  1119. listRequest.reject(new Error('refresh failed'))
  1120. await assert.rejects(refresh, /refresh failed/)
  1121. assert.equal(instance.files, existingFiles)
  1122. assert.equal(instance.count, 1)
  1123. assert.deepEqual(emittedCounts, [])
  1124. instance.resetTeamContext(31)
  1125. assert.equal(instance.files.length, 0)
  1126. assert.equal(instance.count, 0)
  1127. assert.deepEqual(emittedCounts, [0], 'switching teams must still clear the old count immediately')
  1128. }
  1129. async function testTeamWebsiteInputExposesTheAuthoritativeLengthLimit() {
  1130. const source = fs.readFileSync(teamFillComponentPath, 'utf8')
  1131. assert.match(source, /v-model="teamInfo\.enterpriseWebsite"[^>]*maxlength="500"/)
  1132. }
  1133. async function testTeamFillRendersBackgroundFilesAndBusyState() {
  1134. const source = fs.readFileSync(teamFillComponentPath, 'utf8')
  1135. assert.match(
  1136. source,
  1137. /<cus-team-background-files[^>]*ref="backgroundRef"[^>]*>/,
  1138. 'the team form must render the registered background-file component'
  1139. )
  1140. assert.match(
  1141. source,
  1142. /:class="\{ disabled: confirming \|\| submitting \}"/,
  1143. 'the submit button must expose its busy state'
  1144. )
  1145. const component = await loadVueComponent(teamFillComponentPath)
  1146. const state = component.data()
  1147. assert.equal(state.confirming, false, 'the submit lock must be reactive from initialization')
  1148. assert.equal(component.props.confirmText.type, String)
  1149. assert.equal(component.props.qtype.type, Boolean)
  1150. }
  1151. async function testTeamFillFlushesBeforeEmitAndLocks() {
  1152. const component = await loadVueComponent(teamFillComponentPath)
  1153. const flushDeferred = createDeferred()
  1154. let flushCalls = 0
  1155. let emitCalls = 0
  1156. const instance = instantiateComponent(component, {
  1157. teamId: 12,
  1158. qtype: false,
  1159. $props: { qtype: false },
  1160. $showToast() {},
  1161. $emit() { emitCalls += 1 },
  1162. $refs: {
  1163. backgroundRef: {
  1164. flushPendingFiles() { flushCalls += 1; return flushDeferred.promise },
  1165. hasUnresolvedFiles() { return false }
  1166. }
  1167. }
  1168. })
  1169. instance.teamInfo = {
  1170. teamName: '团队', enterpriseName: '公司', enterpriseWebsite: '无',
  1171. districtId: 1, industryId: 2, functionIds: [], orgIds: []
  1172. }
  1173. const first = instance.handleConfirm()
  1174. const second = instance.handleConfirm()
  1175. assert.equal(flushCalls, 1)
  1176. assert.equal(emitCalls, 0)
  1177. flushDeferred.resolve({ success: 1, failed: 0 })
  1178. await Promise.all([first, second])
  1179. assert.equal(emitCalls, 1)
  1180. }
  1181. async function testCreatedTeamResumeAndSubmitLock() {
  1182. const component = await loadVueComponent(createTeamPagePath)
  1183. let teamPostCalls = 0
  1184. let flushCalls = 0
  1185. const toasts = []
  1186. const instance = instantiateComponent(component, {
  1187. $api: {
  1188. post(url) {
  1189. assert.equal(url, '/core/user/team')
  1190. teamPostCalls += 1
  1191. return Promise.resolve({ data: { code: 0, data: { teamId: 88 } } })
  1192. }
  1193. },
  1194. $showToast(message) { toasts.push(message) },
  1195. $showModal: () => Promise.resolve(),
  1196. $refs: {
  1197. teamRef: {
  1198. flushBackgroundFiles() {
  1199. flushCalls += 1
  1200. if (flushCalls === 1) return Promise.reject(new Error('列表刷新失败'))
  1201. return Promise.resolve({ success: 0, failed: 0 })
  1202. },
  1203. hasUnresolvedBackgroundFiles: () => false,
  1204. hasDeferredPcUpload: () => false
  1205. }
  1206. }
  1207. })
  1208. let continuations = 0
  1209. instance.continueAfterTeamCreated = async teamId => {
  1210. assert.equal(teamId, 88)
  1211. continuations += 1
  1212. }
  1213. await instance.handleConfirm({ teamName: '团队' })
  1214. assert.equal(teamPostCalls, 1)
  1215. assert.equal(instance.creationState.createdTeamId, 88)
  1216. assert.ok(toasts.some(message => String(message).includes('背景资料')))
  1217. await instance.handleConfirm({ teamName: '团队' })
  1218. assert.equal(teamPostCalls, 1, 'retry after a later-stage failure must not POST a second team')
  1219. assert.equal(continuations, 1)
  1220. const lockedPost = createDeferred()
  1221. const lockedInstance = instantiateComponent(component, {
  1222. $api: { post: () => { teamPostCalls += 1; return lockedPost.promise } },
  1223. $showToast() {},
  1224. $showModal: () => Promise.resolve(),
  1225. $refs: { teamRef: {} }
  1226. })
  1227. const lockedFirst = lockedInstance.handleConfirm({ teamName: '团队' })
  1228. const callsAfterFirst = teamPostCalls
  1229. const lockedSecond = lockedInstance.handleConfirm({ teamName: '团队' })
  1230. assert.equal(teamPostCalls, callsAfterFirst, 'concurrent submission must be ignored')
  1231. lockedPost.resolve({ data: { code: 1, msg: '保存接口失败' } })
  1232. await Promise.all([lockedFirst, lockedSecond])
  1233. }
  1234. async function testCreatedTeamNavigationIsAwaitableAndResumable() {
  1235. const timers = createFakeTimers()
  1236. let navigationOptions
  1237. let teamPostCalls = 0
  1238. let publishCalls = 0
  1239. let teamPutCalls = 0
  1240. let flushCalls = 0
  1241. const toasts = []
  1242. const uni = {
  1243. getStorageSync(key) {
  1244. assert.equal(key, 'userInfo')
  1245. return JSON.stringify({ id: 7 })
  1246. },
  1247. removeStorageSync() {},
  1248. navigateTo(options) { navigationOptions = options }
  1249. }
  1250. const component = await loadVueComponent(createTeamPagePath, { uni, timers })
  1251. const originalFormat = Date.prototype.Format
  1252. Date.prototype.Format = () => '2026-07-21 12:00:00'
  1253. try {
  1254. const instance = instantiateComponent(component, {
  1255. next: '1', questionnaireId: 66, type: 'survey', title: '问卷',
  1256. $api: {
  1257. post(url, payload) {
  1258. if (url === '/core/user/team') {
  1259. teamPostCalls += 1
  1260. assert.equal(payload.enterpriseWebsite, 'https://old.example')
  1261. return Promise.resolve({ data: { code: 0, data: { teamId: 88 } } })
  1262. }
  1263. assert.equal(url, '/core/team/questionnaire/publish')
  1264. assert.deepEqual(Object.keys(payload).sort(), [
  1265. 'answerSetting', 'backgroundSurveyId', 'coachId', 'endTime', 'questionnaireId',
  1266. 'startTime', 'teamId', 'type'
  1267. ].sort())
  1268. assert.equal(payload.backgroundSurveyId, 456)
  1269. publishCalls += 1
  1270. return Promise.resolve({ data: { code: 0, data: 321 } })
  1271. },
  1272. put(url, payload) {
  1273. teamPutCalls += 1
  1274. assert.equal(url, '/core/user/team')
  1275. assert.equal(payload.id, 88)
  1276. assert.equal(payload.enterpriseWebsite, 'https://new.example')
  1277. if (teamPutCalls === 1) {
  1278. return Promise.resolve({ data: { code: 9, msg: '官网更新失败' } })
  1279. }
  1280. return Promise.resolve({ data: { code: 0, data: true } })
  1281. }
  1282. },
  1283. $showToast(message) { toasts.push(message) },
  1284. $showModal: () => Promise.resolve(),
  1285. $refs: {
  1286. teamRef: {
  1287. flushBackgroundFiles() { flushCalls += 1; return Promise.resolve({ success: 0, failed: 0 }) },
  1288. hasUnresolvedBackgroundFiles: () => false,
  1289. hasDeferredPcUpload: () => false
  1290. }
  1291. }
  1292. })
  1293. instance.creationState.backgroundSurveyId = 456
  1294. instance.creationState.backgroundSurveyStatus = 'COMPLETED'
  1295. const first = instance.handleConfirm({
  1296. teamName: '团队', enterpriseWebsite: 'https://old.example'
  1297. })
  1298. await flushPromises()
  1299. assert.equal(teamPostCalls, 1)
  1300. assert.equal(publishCalls, 1)
  1301. assert.equal(flushCalls, 1)
  1302. assert.equal(instance.creationState.teamQuestionnaireId, 321)
  1303. assert.equal(instance.creationState.completed, false)
  1304. assert.equal(instance.creationState.submitting, true)
  1305. assert.equal(timers.count(), 1)
  1306. timers.runNext()
  1307. await flushPromises()
  1308. assert.ok(navigationOptions)
  1309. assert.equal(instance.creationState.completed, false, 'navigation callback must settle first')
  1310. navigationOptions.fail({ errMsg: 'navigateTo:fail route missing' })
  1311. await first
  1312. assert.equal(instance.creationState.completed, false)
  1313. assert.equal(instance.creationState.submitting, false)
  1314. assert.equal(instance.creationState.stage, 'navigate')
  1315. assert.ok(toasts.some(message => String(message).includes('页面跳转失败')))
  1316. navigationOptions = null
  1317. const failedUpdate = instance.handleConfirm({
  1318. teamName: '团队', enterpriseWebsite: 'https://new.example'
  1319. })
  1320. await failedUpdate
  1321. assert.equal(teamPostCalls, 1)
  1322. assert.equal(publishCalls, 1)
  1323. assert.equal(flushCalls, 1)
  1324. assert.equal(teamPutCalls, 1)
  1325. assert.equal(instance.creationState.stage, 'update')
  1326. assert.ok(toasts.some(message => String(message).includes('团队更新失败:官网更新失败')))
  1327. assert.equal(timers.count(), 0)
  1328. const retry = instance.handleConfirm({
  1329. teamName: '团队', enterpriseWebsite: 'https://new.example'
  1330. })
  1331. await flushPromises()
  1332. assert.equal(teamPostCalls, 1, 'retry must reuse the created team id')
  1333. assert.equal(publishCalls, 1, 'retry after publish success must not publish again')
  1334. assert.equal(flushCalls, 1, 'navigate-stage retry must not repeat file processing')
  1335. assert.equal(teamPutCalls, 2, 'changed sanitized payload must update the same team')
  1336. assert.equal(timers.count(), 1)
  1337. timers.runNext()
  1338. await flushPromises()
  1339. assert.ok(navigationOptions)
  1340. assert.equal(instance.creationState.completed, false)
  1341. navigationOptions.success({})
  1342. await retry
  1343. assert.equal(instance.creationState.completed, true)
  1344. assert.equal(instance.creationState.submitting, false)
  1345. } finally {
  1346. if (originalFormat) Date.prototype.Format = originalFormat
  1347. else delete Date.prototype.Format
  1348. }
  1349. }
  1350. async function testCreatedTeamRequiresCoachSurveyBeforePublish() {
  1351. let navigationOptions
  1352. let publishCalls = 0
  1353. const uni = {
  1354. getStorageSync: () => JSON.stringify({ id: 7 }),
  1355. navigateTo(options) {
  1356. navigationOptions = options
  1357. }
  1358. }
  1359. const component = await loadVueComponent(createTeamPagePath, { uni })
  1360. const instance = instantiateComponent(component, {
  1361. next:'1',
  1362. type:'1',
  1363. title:'PERILL',
  1364. questionnaireId:66,
  1365. $api:{
  1366. post(url) {
  1367. assert.equal(url, '/core/user/team')
  1368. return Promise.resolve({ data:{ code:0, data:{ teamId:88 } } })
  1369. }
  1370. },
  1371. $showToast() {},
  1372. $showModal: () => Promise.resolve(),
  1373. $refs:{
  1374. teamRef:{
  1375. flushBackgroundFiles: () => Promise.resolve({ success:0, failed:0 }),
  1376. hasUnresolvedBackgroundFiles: () => false,
  1377. hasDeferredPcUpload: () => false
  1378. }
  1379. }
  1380. })
  1381. let continuedTeamId
  1382. instance.continueAfterTeamCreated = async teamId => {
  1383. continuedTeamId = teamId
  1384. }
  1385. instance.publishQuestionnaire = async teamId => {
  1386. assert.equal(teamId, 88)
  1387. assert.equal(instance.creationState.backgroundSurveyId, 456)
  1388. publishCalls += 1
  1389. return { code:0, data:321 }
  1390. }
  1391. await instance.handleConfirm({ teamName:'团队', enterpriseWebsite:'无' })
  1392. assert.equal(publishCalls, 0, 'publishing must wait for the coach background survey')
  1393. assert.match(navigationOptions.url, /backgroundSurvey\?mode=coach/)
  1394. assert.equal(instance.creationState.stage, 'coachSurvey')
  1395. navigationOptions.events.backgroundSurveyReady({ surveyId:456, status:'DRAFT' })
  1396. assert.equal(instance.creationState.backgroundSurveyId, 456)
  1397. navigationOptions.events.backgroundSurveyCompleted({ surveyId:456, status:'COMPLETED' })
  1398. component.onShow.call(instance)
  1399. await flushPromises()
  1400. assert.equal(publishCalls, 1)
  1401. assert.equal(instance.creationState.teamQuestionnaireId, 321)
  1402. assert.equal(continuedTeamId, 88)
  1403. assert.equal(instance.creationState.completed, true)
  1404. }
  1405. async function testCreatedTeamUnloadCancelsLateNavigation() {
  1406. const timers = createFakeTimers()
  1407. let navigateBackCalls = 0
  1408. const uni = {
  1409. navigateBack() { navigateBackCalls += 1 }
  1410. }
  1411. const component = await loadVueComponent(createTeamPagePath, { uni, timers })
  1412. const instance = instantiateComponent(component, {
  1413. $api: {
  1414. post: () => Promise.resolve({ data: { code: 0, data: { teamId: 89 } } })
  1415. },
  1416. $showToast() {},
  1417. $showModal: () => Promise.resolve(),
  1418. getOpenerEventChannel: () => ({ emit() {} }),
  1419. $refs: {
  1420. teamRef: {
  1421. flushBackgroundFiles: () => Promise.resolve({ success: 0, failed: 0 }),
  1422. hasUnresolvedBackgroundFiles: () => false,
  1423. hasDeferredPcUpload: () => false
  1424. }
  1425. }
  1426. })
  1427. const submission = instance.handleConfirm({ teamName: '团队', enterpriseWebsite: '无' })
  1428. await flushPromises()
  1429. assert.equal(timers.count(), 1)
  1430. component.onUnload.call(instance)
  1431. assert.equal(timers.count(), 0)
  1432. timers.runAll()
  1433. await submission
  1434. assert.equal(navigateBackCalls, 0)
  1435. assert.equal(instance.creationState.completed, false)
  1436. assert.equal(instance.creationState.submitting, false)
  1437. }
  1438. async function testTeamEditRedirectIsLockedAndResumable() {
  1439. const timers = createFakeTimers()
  1440. let redirectOptions
  1441. let putCalls = 0
  1442. let navigateBackCalls = 0
  1443. const toasts = []
  1444. const uni = {
  1445. redirectTo(options) { redirectOptions = options },
  1446. navigateBack() { navigateBackCalls += 1 }
  1447. }
  1448. const component = await loadVueComponent(teamEditPagePath, { uni, timers })
  1449. const instance = instantiateComponent(component, {
  1450. submitDto: { id: 12, teamName: '团队', enterpriseWebsite: 'https://old.example' },
  1451. show: true,
  1452. $api: {
  1453. put(url, payload) {
  1454. putCalls += 1
  1455. assert.equal(url, '/core/user/team')
  1456. assert.equal(payload.id, 12)
  1457. assert.equal(Object.hasOwn(payload, 'coachId'), false)
  1458. assert.equal(Object.hasOwn(payload, 'uploaderId'), false)
  1459. assert.equal(Object.hasOwn(payload, 'source'), false)
  1460. assert.equal(payload.enterpriseWebsite,
  1461. putCalls === 1 ? 'https://old.example' : 'https://new.example')
  1462. return Promise.resolve({ data: { code: 0, data: true } })
  1463. }
  1464. },
  1465. $showToast(message) { toasts.push(message) }
  1466. })
  1467. const first = instance.editConfirm()
  1468. await flushPromises()
  1469. assert.equal(putCalls, 1)
  1470. assert.equal(instance.saveSucceeded, true)
  1471. assert.equal(instance.saving, true, 'save lock must cover the redirect delay')
  1472. assert.equal(timers.count(), 1)
  1473. instance.editConfirm()
  1474. instance.requestBack()
  1475. assert.equal(putCalls, 1, 'confirm during redirect delay must not PUT again')
  1476. assert.equal(navigateBackCalls, 0, 'back during redirect delay must stay locked')
  1477. assert.ok(toasts.some(message => String(message).includes('正在保存')))
  1478. timers.runNext()
  1479. await flushPromises()
  1480. assert.ok(redirectOptions)
  1481. assert.equal(instance.saving, true, 'redirect callback must settle before unlock')
  1482. redirectOptions.fail({ errMsg: 'redirectTo:fail route missing' })
  1483. await first
  1484. assert.equal(instance.saving, false)
  1485. assert.equal(instance.saveSucceeded, true)
  1486. assert.equal(instance.saveStage, 'navigate')
  1487. redirectOptions = null
  1488. const retry = instance.editConfirm()
  1489. await flushPromises()
  1490. assert.equal(putCalls, 1, 'redirect retry must reuse the successful PUT')
  1491. assert.equal(instance.saving, true)
  1492. assert.equal(timers.count(), 1)
  1493. timers.runNext()
  1494. await flushPromises()
  1495. assert.ok(redirectOptions)
  1496. redirectOptions.fail({ errMsg: 'redirectTo:fail still missing' })
  1497. await retry
  1498. assert.equal(putCalls, 1)
  1499. redirectOptions = null
  1500. instance.handleConfirm({
  1501. id: 12,
  1502. teamName: '团队',
  1503. enterpriseWebsite: ' https://new.example ',
  1504. coachId: 7,
  1505. uploaderId: 8,
  1506. source: 'WECHAT'
  1507. })
  1508. const changedRetry = instance.editConfirm()
  1509. await flushPromises()
  1510. assert.equal(putCalls, 2, 'a changed payload must update the saved team before redirect')
  1511. assert.equal(instance.saving, true)
  1512. assert.equal(timers.count(), 1)
  1513. timers.runNext()
  1514. await flushPromises()
  1515. assert.ok(redirectOptions)
  1516. redirectOptions.success({})
  1517. await changedRetry
  1518. assert.equal(putCalls, 2)
  1519. assert.equal(instance.saving, false)
  1520. }
  1521. async function testTeamEditUnloadCancelsLateRedirect() {
  1522. const timers = createFakeTimers()
  1523. let redirectCalls = 0
  1524. const component = await loadVueComponent(teamEditPagePath, {
  1525. timers,
  1526. uni: { redirectTo() { redirectCalls += 1 } }
  1527. })
  1528. const instance = instantiateComponent(component, {
  1529. submitDto: { id: 12 },
  1530. $api: { put: () => Promise.resolve({ data: { code: 0, data: true } }) },
  1531. $showToast() {}
  1532. })
  1533. const saving = instance.editConfirm()
  1534. await flushPromises()
  1535. assert.equal(timers.count(), 1)
  1536. component.onUnload.call(instance)
  1537. assert.equal(timers.count(), 0)
  1538. timers.runAll()
  1539. await saving
  1540. assert.equal(redirectCalls, 0)
  1541. assert.equal(instance.saving, false)
  1542. }
  1543. async function testCloseAndBackRequirePendingUploadConfirmation() {
  1544. let modalOptions
  1545. let navigateBackCalls = 0
  1546. const uni = {
  1547. showModal(options) { modalOptions = options },
  1548. navigateBack() { navigateBackCalls += 1 }
  1549. }
  1550. const createList = await loadVueComponent(createListComponentPath, { uni })
  1551. const createListInstance = instantiateComponent(createList, {
  1552. $imgBase: 'https://image.example/',
  1553. teamInfoShow: true,
  1554. $refs: {
  1555. teamRef: {
  1556. hasUnresolvedBackgroundFiles: () => true,
  1557. isBackgroundUploadActive: () => false
  1558. }
  1559. }
  1560. })
  1561. createListInstance.requestCloseTeamInfo()
  1562. assert.equal(createListInstance.teamInfoShow, true)
  1563. modalOptions.success({ confirm: false })
  1564. assert.equal(createListInstance.teamInfoShow, true)
  1565. createListInstance.requestCloseTeamInfo()
  1566. modalOptions.success({ confirm: true })
  1567. assert.equal(createListInstance.teamInfoShow, false)
  1568. const teamEdit = await loadVueComponent(teamEditPagePath, { uni })
  1569. const teamEditInstance = instantiateComponent(teamEdit, {
  1570. $refs: {
  1571. teamRef: {
  1572. hasUnresolvedBackgroundFiles: () => true,
  1573. isBackgroundUploadActive: () => true
  1574. }
  1575. }
  1576. })
  1577. teamEditInstance.requestBack()
  1578. assert.equal(navigateBackCalls, 0)
  1579. modalOptions.success({ confirm: false })
  1580. assert.equal(navigateBackCalls, 0)
  1581. teamEditInstance.requestBack()
  1582. modalOptions.success({ confirm: true })
  1583. assert.equal(navigateBackCalls, 1)
  1584. let headerBackEvents = 0
  1585. const header = await loadVueComponent(headerComponentPath, { uni })
  1586. const headerInstance = instantiateComponent(header, {
  1587. interceptBack: true,
  1588. $emit(name) { if (name === 'back') headerBackEvents += 1 }
  1589. })
  1590. headerInstance.toBack('')
  1591. assert.equal(headerBackEvents, 1)
  1592. assert.equal(navigateBackCalls, 1, 'intercepted header back must not navigate directly')
  1593. }
  1594. async function main() {
  1595. const loadedTransport = await loadTransport()
  1596. assert.equal(typeof loadedTransport.downloadTeamBackgroundFile, 'function')
  1597. await testConcurrentSilentRequestDoesNotStrandLoadingMask()
  1598. await testUploadTransport()
  1599. await testApiTransport()
  1600. await testDownloadTransport()
  1601. await testBackgroundSurveyPagesLoad()
  1602. await testDeferredPcSelectionSurvivesSessionFailure()
  1603. await testSessionRefreshFailureKeepsOldTimer()
  1604. await testPcSessionCreationIsSingleFlightPerTeam()
  1605. await testPcSessionSingleFlightSurvivesSameTeamReset()
  1606. await testPcSessionFailureClearsSingleFlightForRetry()
  1607. await testDeferredPcFinishWaitsForListRefresh()
  1608. await testDeferredPcWaiterSingleFlightFinishesAllCallers()
  1609. await testTeamContextResetAndUploadIsolation()
  1610. await testFileActionsWaitForCurrentListAndDocumentContext()
  1611. await testUploadedFileUsesDeleteWordingAndRefreshesAfterDelete()
  1612. await testSameTeamRefreshFailurePreservesExistingFilesAndCount()
  1613. await testTeamWebsiteInputExposesTheAuthoritativeLengthLimit()
  1614. await testTeamFillRendersBackgroundFilesAndBusyState()
  1615. await testTeamFillFlushesBeforeEmitAndLocks()
  1616. await testCreatedTeamResumeAndSubmitLock()
  1617. await testCreatedTeamNavigationIsAwaitableAndResumable()
  1618. await testCreatedTeamRequiresCoachSurveyBeforePublish()
  1619. await testCreatedTeamUnloadCancelsLateNavigation()
  1620. await testTeamEditRedirectIsLockedAndResumable()
  1621. await testTeamEditUnloadCancelsLateRedirect()
  1622. await testCloseAndBackRequirePendingUploadConfirmation()
  1623. console.log('team background transport: PASS')
  1624. console.log('team background component behavior: PASS')
  1625. }
  1626. main().catch(error => {
  1627. console.error(error)
  1628. process.exitCode = 1
  1629. })