merchant-conversation.mjs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import assert from 'node:assert/strict';
  2. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  3. import { chromium } from 'playwright';
  4. const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
  5. const base = process.env.FRONTEND_URL || 'http://127.0.0.1:18762';
  6. const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE });
  7. const context = await browser.newContext();
  8. const page = await context.newPage();
  9. const errors = [], checks = [];
  10. page.on('pageerror', error => errors.push(error.message));
  11. let customerToken;
  12. async function settle() {
  13. await page.waitForFunction(() => !document.querySelector('.merchant-loading'));
  14. }
  15. async function route(path) {
  16. await page.evaluate(value => { location.hash = `/${value}`; }, path);
  17. await page.waitForURL(`**/#/${path}`);
  18. await settle();
  19. }
  20. async function login(username) {
  21. await page.goto(`${base}/#/merchant/login`);
  22. await page.getByLabel('商家标识').fill(accounts.tenant_id);
  23. await page.getByLabel('员工账户').fill(username);
  24. await page.getByLabel('密码', { exact: true }).fill(accounts.password);
  25. await page.getByRole('button', { name: '登录', exact: true }).click();
  26. await page.locator('.admin-shell').waitFor();
  27. await settle();
  28. }
  29. async function confirm() {
  30. await page.getByRole('dialog').getByRole('button', { name: '确认操作', exact: true }).click();
  31. await page.getByRole('dialog').waitFor({ state: 'hidden' });
  32. await settle();
  33. }
  34. async function customerRequest(method, path, data, expected = 200) {
  35. const response = await context.request.fetch(`${base}${path}`, {
  36. method, headers: { Authorization: `Bearer ${customerToken}`, 'Idempotency-Key': crypto.randomUUID() }, data,
  37. });
  38. assert.equal(response.status(), expected, await response.text());
  39. return expected === 204 ? null : response.json();
  40. }
  41. async function openQueueConversation(id) {
  42. await route('merchant/service/queue');
  43. for (let index = 0; index < 50; index += 1) {
  44. const row = page.locator('.session-row').filter({ hasText: id });
  45. if (await row.count()) { await row.click(); await page.waitForURL('**/#/merchant/service/conversation'); await settle(); return; }
  46. const more = page.getByRole('button', { name: '加载更多', exact: true });
  47. assert.equal(await more.count(), 1, '真实会话必须能通过队列分页找到');
  48. const response = page.waitForResponse(item => item.request().method() === 'GET' && item.url().includes('/openapi/v1/conversations?'));
  49. await more.click(); assert.equal((await response).status(), 200); await settle();
  50. }
  51. throw new Error('会话队列超过本次验证的 50 页限制');
  52. }
  53. try {
  54. const response = await context.request.post(`${base}/openapi/v1/auth/sessions`, {
  55. data: { tenant_id: accounts.tenant_id, username: 'customer01', password: accounts.password },
  56. });
  57. assert.equal(response.status(), 200);
  58. customerToken = (await response.json()).access_token;
  59. // 使用真实客户接口准备待整理工单,全程不发送 AGENT 模式客户消息。
  60. const conversation = await customerRequest('POST', '/openapi/v1/conversations', { store_id: accounts.store_id }, 201);
  61. const order = await customerRequest('GET', `/openapi/v1/orders/${accounts.order_id}`);
  62. const original = await customerRequest('POST', '/openapi/v1/after-sales/proposals', {
  63. order_id: order.order_id, item_id: order.item_id, quantity: 1, reason: `客服整理浏览器验收 ${Date.now()}`,
  64. }, 201);
  65. await login('staff01');
  66. await openQueueConversation(conversation.conversation_id);
  67. await page.locator('.merchant-pages').getByText(original.citation, { exact: true }).waitFor();
  68. assert.match(await page.locator('.merchant-pages').innerText(), new RegExp(order.order_id));
  69. await page.getByRole('button', { name: '接管会话', exact: true }).click();
  70. await confirm();
  71. const reply = `客服已接收本次资料 ${Date.now()}`;
  72. await page.getByLabel('人工回复').fill(reply);
  73. const replied = page.waitForResponse(item => item.request().method() === 'POST' && item.url().endsWith(`/conversations/${conversation.conversation_id}/messages`));
  74. await page.getByRole('button', { name: '发送回复', exact: true }).click();
  75. assert.equal((await replied).status(), 201);
  76. await page.locator('.message').filter({ hasText: reply }).waitFor();
  77. const events = await customerRequest('GET', `/openapi/v1/conversations/${conversation.conversation_id}/messages?limit=100`);
  78. assert(events.items.some(item => item.type === 'message.staff' && item.payload.text === reply));
  79. checks.push('客服从真实队列恢复客户订单与当前工单,接管回复对客户可见');
  80. await page.getByRole('button', { name: '整理换货方案', exact: true }).click();
  81. await page.waitForURL('**/#/merchant/service/draft');
  82. await settle();
  83. assert.equal(await page.getByLabel('故障描述').inputValue(), original.reason);
  84. const reason = `客服已核查按键故障 ${Date.now()}`;
  85. await page.getByLabel('故障描述').fill(reason);
  86. const revised = page.waitForResponse(item => item.request().method() === 'POST' && item.url().endsWith(`/proposals/${original.proposal_id}/revisions`));
  87. await page.getByRole('button', { name: '保存并交客户检查', exact: true }).click();
  88. const revisionResponse = await revised;
  89. assert.equal(revisionResponse.status(), 200, await revisionResponse.text());
  90. const ticket = await revisionResponse.json();
  91. assert.equal(ticket.proposal_version, original.proposal_version + 1);
  92. assert.equal(ticket.submitted_by, accounts.actors.staff01);
  93. await page.waitForURL('**/#/merchant/service/ticket');
  94. await settle();
  95. assert.match(await page.locator('.merchant-pages').innerText(), new RegExp(reason));
  96. const persisted = await customerRequest('GET', `/openapi/v1/tickets/${ticket.ticket_id}`);
  97. assert.equal(persisted.reason, reason);
  98. const confirmed = await customerRequest('POST', `/openapi/v1/after-sales/proposals/${ticket.proposal_id}/confirm`, {
  99. proposal_version: ticket.proposal_version, expected_ticket_version: ticket.version,
  100. });
  101. assert.equal(confirmed.status, 'WAITING_REVIEW');
  102. checks.push('客服修订生成新方案版本,客户读取相同正文并本人确认');
  103. await route('merchant/service/conversation');
  104. await page.getByRole('button', { name: '结束接管', exact: true }).click();
  105. await confirm();
  106. assert.equal(await page.getByRole('button', { name: '发送回复', exact: true }).isDisabled(), true);
  107. await login('supervisor01');
  108. await route('merchant/approval/approvals');
  109. const row = page.locator('tr').filter({ hasText: ticket.ticket_id });
  110. await row.getByRole('button', { name: '查看并审批', exact: true }).click();
  111. await page.waitForURL('**/#/merchant/approval/review');
  112. await settle();
  113. await page.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '审批决定', exact: true }) }).click();
  114. await page.getByRole('option', { name: '拒绝', exact: true }).click();
  115. await page.getByLabel('审批意见').fill('已核查浏览器验收申请,本次结束申请。');
  116. await page.locator('.merchant-pages .el-checkbox').click();
  117. assert.equal(await page.getByRole('checkbox').isChecked(), true);
  118. await page.getByRole('button', { name: '提交审批决定', exact: true }).click();
  119. await confirm();
  120. const result = await customerRequest('GET', `/openapi/v1/tickets/${ticket.ticket_id}`);
  121. assert.equal(result.status, 'REJECTED');
  122. assert.equal(result.decision_reason, '已核查浏览器验收申请,本次结束申请。');
  123. checks.push('客服释放接管后不能回复,主管审批结果持久保存并向客户返回');
  124. assert.deepEqual(errors, []);
  125. await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
  126. await writeFile(new URL('../../.work/frontend-verification/merchant-conversation.json', import.meta.url), JSON.stringify({
  127. date: new Date().toISOString(), checks, browserErrors: errors, generatedModelCalls: 0,
  128. }, null, 2));
  129. console.log(checks.join('\n'));
  130. } finally {
  131. if (customerToken) await customerRequest('DELETE', '/openapi/v1/auth/sessions/current', undefined, 204);
  132. await browser.close();
  133. }