admin_realtime_client.mjs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import assert from 'node:assert/strict';
  2. import { readFileSync } from 'node:fs';
  3. import { test } from 'node:test';
  4. // 执行交付文档中的实际参考类,避免测试一份与交付内容不同的实现。
  5. const document = readFileSync(new URL('../../docs/总后台/后台实时推送-前端接入.md', import.meta.url), 'utf8');
  6. const block = [...document.matchAll(/```js\n([\s\S]*?)\n```/g)]
  7. .map((match) => match[1]).find((code) => code.includes('export class AdminRealtime'));
  8. const source = block.slice(block.indexOf('export class AdminRealtime'), block.indexOf('// 体彩订单列表页'))
  9. .replace('export class', 'class');
  10. const A = 'admin.orders.withdraw';
  11. const B = 'admin.orders.sport';
  12. const flush = () => new Promise((resolve) => setImmediate(resolve));
  13. function deferred() {
  14. let resolve;
  15. const promise = new Promise((done) => { resolve = done; });
  16. return { promise, resolve };
  17. }
  18. class Subscription {
  19. listeners = new Map();
  20. listen(name, callback) { this.listeners.set(name, callback); return this; }
  21. subscribed(callback) { this.ready = callback; return this; }
  22. error(callback) { this.onError = callback; return this; }
  23. emit(event) { this.listeners.get(`.${event.event}`)?.(event); }
  24. }
  25. class Echo {
  26. channels = new Map();
  27. connector = { pusher: { connection: { bind: (name, callback) => { this.changeState = callback; } } } };
  28. private(channel) {
  29. if (!this.channels.has(channel)) this.channels.set(channel, new Subscription());
  30. return this.channels.get(channel);
  31. }
  32. leave(channel) { this.channels.delete(channel); }
  33. disconnect() { this.changeState?.({ current: 'disconnected' }); }
  34. }
  35. async function setup() {
  36. const requests = [];
  37. const http = { get(url, options) {
  38. if (url.endsWith('/config')) return Promise.resolve({ code: 0, data: { channels: [A, B], last_seq: 0 } });
  39. const response = deferred();
  40. requests.push({ ...response, ...options.params });
  41. return response.promise;
  42. } };
  43. const Client = new Function('Echo', 'Pusher', 'API_BASE', `${source}\nreturn AdminRealtime;`)(Echo, {}, '');
  44. const client = new Client(http, 'fixture-token', () => {});
  45. await client.start();
  46. return { client, echo: client.echo, requests };
  47. }
  48. const event = (seq, action = 'updated', channel = A, key = 88) => ({
  49. seq, channel, action, event: `order.${action}`, key: { id: key }, data: action === 'deleted' ? null : { id: key, version: seq },
  50. });
  51. const response = (events, lastSeq = 10) => ({ code: 0, data: { resync: false, last_seq: lastSeq, events } });
  52. const watch = (client, channel, callback, reload = async () => {}) => client.watch(channel,
  53. ['order.created', 'order.updated', 'order.deleted'], callback, reload);
  54. test('older replay cannot resurrect a row deleted by a newer live event', async () => {
  55. const { client, echo, requests } = await setup();
  56. const rows = new Map();
  57. watch(client, A, (message) => {
  58. if (message.action === 'deleted') rows.delete(message.key.id);
  59. else rows.set(message.key.id, message.data);
  60. });
  61. echo.private(A).ready();
  62. echo.private(A).emit(event(2, 'deleted'));
  63. requests[0].resolve(response([event(1, 'created')]));
  64. await flush();
  65. assert.equal(rows.has(88), false);
  66. client.deliver(event(1, 'created'));
  67. assert.equal(rows.has(88), false, 'retain the deletion watermark after replay completes');
  68. echo.private(A).emit(event(3, 'created'));
  69. assert.equal(rows.get(88).version, 3, 'a genuinely newer creation is still applied');
  70. });
  71. test('old HTTP completion cannot clear the new connection resume point', async () => {
  72. const { client, echo, requests } = await setup();
  73. const received = [];
  74. watch(client, A, (message) => received.push(message.seq));
  75. echo.private(A).ready();
  76. echo.changeState({ current: 'unavailable' });
  77. requests[0].resolve(response([event(1)], 1));
  78. await flush();
  79. assert.deepEqual(received, [], 'discard the response belonging to the disconnected generation');
  80. echo.private(A).ready();
  81. assert.equal(requests.length, 2);
  82. assert.equal(requests[1].after_seq, 0);
  83. requests[1].resolve(response([event(2)], 2));
  84. await flush();
  85. assert.deepEqual(received, [2]);
  86. });
  87. test('unwatch and rewatch isolate pending responses for the same channel', async () => {
  88. const { client, echo, requests } = await setup();
  89. const old = [];
  90. const current = [];
  91. watch(client, A, (message) => old.push(message.seq));
  92. echo.private(A).ready();
  93. client.unwatch(A);
  94. watch(client, A, (message) => current.push(message.seq));
  95. echo.private(A).ready();
  96. requests[0].resolve(response([event(1)], 1));
  97. await flush();
  98. requests[1].resolve(response([event(2)], 2));
  99. await flush();
  100. assert.deepEqual(old, []);
  101. assert.deepEqual(current, [2]);
  102. });
  103. test('channels catch up independently even when another response has a greater last_seq', async () => {
  104. const { client, echo, requests } = await setup();
  105. const received = [];
  106. for (const channel of [A, B]) {
  107. watch(client, channel, (message) => received.push([message.channel, message.seq]));
  108. echo.private(channel).ready();
  109. }
  110. requests[0].resolve(response([event(90)], 100));
  111. await flush();
  112. requests[1].resolve(response([event(10, 'updated', B)], 100));
  113. await flush();
  114. assert.deepEqual(received, [[A, 90], [B, 10]]);
  115. assert.deepEqual(requests.map((r) => r.after_seq), [0, 0]);
  116. });
  117. test('resync buffers live messages until the HTTP list reload finishes', async () => {
  118. const { client, echo, requests } = await setup();
  119. const list = deferred();
  120. const received = [];
  121. watch(client, A, (message) => received.push(message.seq), () => list.promise);
  122. echo.private(A).ready();
  123. requests[0].resolve({ code: 0, data: { resync: true, last_seq: 5, events: [] } });
  124. await flush();
  125. echo.private(A).emit(event(5));
  126. echo.private(A).emit(event(6));
  127. assert.deepEqual(received, []);
  128. list.resolve();
  129. await flush();
  130. assert.deepEqual(received, [6]);
  131. });
  132. test('stop invalidates pending responses and removes previous session state', async () => {
  133. const { client, echo, requests } = await setup();
  134. const received = [];
  135. watch(client, A, (message) => received.push(message.seq));
  136. echo.private(A).ready();
  137. client.stop();
  138. requests[0].resolve(response([event(1)]));
  139. await flush();
  140. assert.deepEqual(received, []);
  141. assert.equal(client.pending.size, 0);
  142. assert.equal(client.latest.size, 0);
  143. });