promotion.test.mjs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { readFile } from 'node:fs/promises';
  4. import { parse, compileScript } from '@vue/compiler-sfc';
  5. import ts from 'typescript';
  6. import * as vue from 'vue';
  7. import { prepareCommand, mayHaveCommitted } from '../src/utils/command-core.mjs';
  8. const source = await readFile(new URL('../src/pages/account/rewards.vue', import.meta.url), 'utf8');
  9. const { descriptor } = parse(source, { filename: 'rewards.vue' });
  10. const script = compileScript(descriptor, { id: 'promotion-test' });
  11. const resourceSource = await readFile(
  12. new URL('../src/composables/useResource.ts', import.meta.url),
  13. 'utf8',
  14. );
  15. const commandSource = await readFile(
  16. new URL('../src/composables/useCommand.ts', import.meta.url),
  17. 'utf8',
  18. );
  19. function evaluate(source, dependencies) {
  20. const { outputText } = ts.transpileModule(source, {
  21. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
  22. });
  23. const exports = {};
  24. new Function('require', 'exports', outputText)((name) => {
  25. if (name in dependencies) return dependencies[name];
  26. if (name.endsWith('.vue')) return {};
  27. throw new Error('Unexpected dependency: ' + name);
  28. }, exports);
  29. return exports;
  30. }
  31. const flush = () => new Promise((resolve) => setImmediate(resolve));
  32. class ApiFailure extends Error {
  33. constructor(message, status = 0) {
  34. super(message);
  35. this.status = status;
  36. }
  37. }
  38. function fixture() {
  39. const hooks = {},
  40. storage = new Map(),
  41. posts = [];
  42. const session = vue.reactive({ token: '', userId: '', epoch: 0, active: null });
  43. const code = 'AABBCCDDEEFF00112233';
  44. globalThis.uni = {
  45. getStorageSync: (key) => storage.get(key) || '',
  46. setStorageSync: (key, value) => storage.set(key, value),
  47. removeStorageSync: (key) => storage.delete(key),
  48. stopPullDownRefresh() {},
  49. showToast() {},
  50. };
  51. const response = {
  52. enabled: true,
  53. invite_code: code,
  54. total_buckets: 0,
  55. total: 0,
  56. items: [],
  57. bound: null,
  58. policy: {},
  59. };
  60. const client = {
  61. ApiFailure,
  62. get: async () => response,
  63. post: (path, payload) =>
  64. new Promise((resolve, reject) => posts.push({ path, payload, resolve, reject })),
  65. };
  66. const format = { toast() {} };
  67. const resource = evaluate(resourceSource, { vue, '../stores/session': { session } });
  68. const command = evaluate(commandSource, {
  69. vue,
  70. '../stores/session': { session },
  71. '../api/client': client,
  72. '../utils/command-core.mjs': { prepareCommand, mayHaveCommitted },
  73. '../utils/format': format,
  74. });
  75. const state = evaluate(script.content, {
  76. vue,
  77. '@dcloudio/uni-app': {
  78. onLoad: (fn) => (hooks.load = fn),
  79. onShow: (fn) => (hooks.show = fn),
  80. onShareAppMessage: (fn) => (hooks.share = fn),
  81. },
  82. '../../api/client': client,
  83. '../../stores/session': {
  84. session,
  85. resourcePath: () => `/enterprises/${session.active.resource_id}`,
  86. canManageEnterprise: () => session.active?.role === 'enterprise_admin',
  87. },
  88. '../../composables/useResource': resource,
  89. '../../composables/useCommand': command,
  90. '../../utils/format': format,
  91. }).default.setup({}, { expose() {} });
  92. const login = () =>
  93. Object.assign(session, {
  94. token: 'a'.repeat(64),
  95. userId: 'promotion-user',
  96. active: { role: 'enterprise_admin', resource_id: 42 },
  97. });
  98. return { state, hooks, storage, posts, session, login, code };
  99. }
  100. test('shared invitation survives login and does not bind until explicitly confirmed', async () => {
  101. const app = fixture();
  102. app.hooks.load({ invite_code: app.code.toLowerCase() });
  103. app.hooks.show();
  104. assert.equal(app.state.inviteInput.value, app.code);
  105. assert.equal(app.posts.length, 0);
  106. app.login();
  107. app.hooks.show();
  108. await flush();
  109. assert.equal(app.posts.length, 0, 'opening a shared page must never write attribution');
  110. app.state.bind();
  111. app.state.bind();
  112. assert.equal(app.posts.length, 1, 'double confirmation is serialized');
  113. assert.equal(app.posts[0].path, '/enterprises/42/promotion/bind');
  114. assert.equal(app.posts[0].payload.invite_code, app.code);
  115. assert.match(app.posts[0].payload.request_key, /^[A-Za-z0-9_-]{8,80}$/);
  116. app.posts[0].resolve({ id: 1 });
  117. await flush();
  118. assert.equal(
  119. app.storage.has('water.promotion-invite'),
  120. false,
  121. 'clear pending invitation only after success',
  122. );
  123. });
  124. test('invalid share parameters cannot create or bind an invitation', async () => {
  125. const app = fixture();
  126. app.hooks.load({ invite_code: 'https://untrusted.example/?token=anything' });
  127. app.login();
  128. app.hooks.show();
  129. await flush();
  130. assert.equal(app.state.inviteInput.value, '');
  131. app.state.inviteInput.value = 'invalid';
  132. app.state.bind();
  133. assert.equal(app.posts.length, 0);
  134. assert.equal(app.storage.has('water.promotion-invite'), false);
  135. });
  136. test('an unknown binding result retains its payload and cannot be replaced by another inviter', async () => {
  137. const app = fixture();
  138. app.login();
  139. app.state.inviteInput.value = app.code;
  140. app.state.bind();
  141. const original = app.posts[0].payload;
  142. app.posts[0].reject(new ApiFailure('网络超时'));
  143. await flush();
  144. assert.equal(app.state.binding.pending, true);
  145. app.state.inviteInput.value = '00112233445566778899';
  146. app.state.bind();
  147. await flush();
  148. assert.equal(app.posts.length, 1, 'changed inviter must not replace unresolved binding');
  149. const retry = app.state.binding.retry();
  150. assert.deepEqual(app.posts[1].payload, original);
  151. app.posts[1].resolve({ id: 1, replayed: true });
  152. await retry;
  153. });
  154. test('shared links contain only the public enterprise code and clear it when identity changes', async () => {
  155. const app = fixture();
  156. app.login();
  157. app.hooks.show();
  158. await flush();
  159. assert.equal(app.hooks.share().path, '/pages/account/rewards?invite_code=' + app.code);
  160. assert.equal(app.hooks.share().path.includes(app.session.token), false);
  161. app.session.epoch++;
  162. assert.equal(app.state.data.value, null);
  163. assert.equal(app.hooks.share().path, '/pages/account/rewards');
  164. });