@tbox.cn/app-module-member 0.1.0 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +99 -11
  3. package/dist/chunk-KEBILLQ5.js +771 -0
  4. package/dist/chunk-T4SYPQA5.js +1565 -0
  5. package/dist/client/index.d.ts +11 -2
  6. package/dist/client/index.js +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +14 -4
  9. package/dist/login-mascot-5D4X6YZ7.jpg +0 -0
  10. package/dist/member-QFCIYILN.css +1734 -0
  11. package/dist/server/index.d.ts +928 -41
  12. package/dist/server/index.js +13 -3
  13. package/package.json +25 -16
  14. package/skills/member/SKILL.md +141 -0
  15. package/skills/member/references/enrollment.md +34 -0
  16. package/skills/member/references/points-and-card.md +24 -0
  17. package/src/client/assets/login-mascot.jpg +0 -0
  18. package/src/client/assets.d.ts +5 -0
  19. package/src/client/cards/index.ts +10 -0
  20. package/src/client/cards/member-card-summary/index.tsx +375 -0
  21. package/src/client/cards/member-enrollment-cta/index.tsx +61 -0
  22. package/src/client/cards/member-enrollment-form/index.tsx +181 -0
  23. package/src/client/cards/member-enrollment-status/index.tsx +65 -0
  24. package/src/client/cards/member-info/index.tsx +60 -0
  25. package/src/client/cards/member-login-cta/index.tsx +81 -0
  26. package/src/client/cards/member-point-usage-list/index.tsx +53 -0
  27. package/src/client/cards/view.ts +39 -0
  28. package/src/client/components/ImageWithFallback.tsx +38 -0
  29. package/src/client/index.ts +21 -4
  30. package/src/client/styles/member.css +1734 -0
  31. package/src/client/types/css.d.ts +2 -0
  32. package/src/server/action.ts +348 -0
  33. package/src/server/cards/index.ts +31 -0
  34. package/src/server/cards/member-card-summary/meta.ts +42 -0
  35. package/src/server/cards/member-card-summary/samples.ts +272 -0
  36. package/src/server/cards/member-enrollment-cta/meta.ts +19 -0
  37. package/src/server/cards/member-enrollment-form/meta.ts +26 -0
  38. package/src/server/cards/member-enrollment-status/meta.ts +18 -0
  39. package/src/server/cards/member-info/meta.ts +19 -0
  40. package/src/server/cards/member-login-cta/meta.ts +16 -0
  41. package/src/server/cards/member-point-usage-list/meta.ts +19 -0
  42. package/src/server/handler.ts +170 -16
  43. package/src/server/index.ts +136 -24
  44. package/src/server/ports.ts +66 -0
  45. package/src/server/service.ts +188 -36
  46. package/src/server/status.ts +87 -0
  47. package/src/server/subject-binding.ts +19 -0
  48. package/src/server/tool.ts +514 -19
  49. package/src/server/util.ts +4 -0
  50. package/tbox.module.json +124 -0
  51. package/tests/cards-render.test.tsx +526 -0
  52. package/tests/enrollment.test.ts +121 -0
  53. package/tests/ports-resolve.test.ts +95 -0
  54. package/tests/preference-memory.test.ts +86 -0
  55. package/tests/samples.test.ts +61 -0
  56. package/tests/service.test.ts +597 -36
  57. package/tests/skills.test.ts +65 -0
  58. package/tests/status.test.ts +127 -0
  59. package/tests/view.test.ts +33 -0
  60. package/tsup.config.ts +9 -1
  61. package/dist/chunk-F6FHBH3N.js +0 -160
  62. package/dist/chunk-OXXT6F5B.js +0 -33
  63. package/src/client/cards/points/index.tsx +0 -19
  64. package/src/server/cards/points/meta.ts +0 -22
  65. package/tbox.component.json +0 -20
@@ -0,0 +1,526 @@
1
+ /**
2
+ * member 客户端卡片渲染测试(0.4.0 D9:happy-dom)。
3
+ * 重点:入会 CTA/表单/状态卡的显式动作、历史禁用、校验与防重复提交。
4
+ */
5
+ // @vitest-environment happy-dom
6
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
7
+ import { createRoot } from 'react-dom/client';
8
+ import { act } from 'react-dom/test-utils';
9
+ import { sendCardAction } from '@tbox.cn/app-sdk/client';
10
+ import {
11
+ MemberCardSummaryCard,
12
+ MemberEnrollmentCtaCard,
13
+ MemberLoginCtaCard,
14
+ MemberEnrollmentFormCard,
15
+ MemberEnrollmentStatusCard,
16
+ MemberInfoCard,
17
+ MemberPointUsageListCard,
18
+ } from '../src/client/cards';
19
+ import MemberCardSummaryCardDefault from '../src/client/cards/member-card-summary';
20
+ import MemberEnrollmentFormCardDefault from '../src/client/cards/member-enrollment-form';
21
+ import MemberEnrollmentStatusCardDefault from '../src/client/cards/member-enrollment-status';
22
+
23
+ vi.mock('@tbox.cn/app-sdk/client', async (importOriginal) => {
24
+ const mod = await importOriginal<typeof import('@tbox.cn/app-sdk/client')>();
25
+ return { ...mod, sendCardAction: vi.fn() };
26
+ });
27
+
28
+ const mockedSend = () => vi.mocked(sendCardAction);
29
+
30
+ function render(ui: React.ReactElement) {
31
+ const host = document.createElement('div');
32
+ document.body.appendChild(host);
33
+ const root = createRoot(host);
34
+ act(() => {
35
+ root.render(ui);
36
+ });
37
+ return { host, root };
38
+ }
39
+
40
+ const formData = {
41
+ userId: 'u1',
42
+ state: 'not_enrolled',
43
+ formVersion: 'v1',
44
+ fields: [
45
+ { fieldId: 'mobile', label: '手机号', type: 'tel', required: true, readOnly: true, prefill: '138****8888' },
46
+ { fieldId: 'name', label: '姓名', type: 'text', required: true },
47
+ { fieldId: 'gender', label: '性别', type: 'select', required: false, options: ['男', '女'] },
48
+ ],
49
+ consents: [{ consentId: 'privacy', version: '1', title: '隐私政策', required: true }],
50
+ authorization: { status: 'required' },
51
+ };
52
+
53
+ describe('MemberEnrollmentFormCard 交互矩阵', () => {
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ });
57
+
58
+ it('select 字段渲染为 <select> 且含 options', () => {
59
+ const { host } = render(
60
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={false} actionTokens={{ 'member.enroll': 'tok', 'member.request-alipay-authorization': 'auth' }} />,
61
+ );
62
+ const select = host.querySelector('select') as HTMLSelectElement;
63
+ expect(select).not.toBeNull();
64
+ expect(select.querySelectorAll('option').length).toBe(3);
65
+ });
66
+
67
+ it('required 授权仅在用户显式点击后触发,且点击后防重复派发', () => {
68
+ const { host } = render(
69
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={false} actionTokens={{ 'member.enroll': 'tok', 'member.request-alipay-authorization': 'auth' }} />,
70
+ );
71
+ expect(mockedSend()).not.toHaveBeenCalled();
72
+ const auth = host.querySelector('.member-enrollment-form__auth-btn') as HTMLButtonElement;
73
+ act(() => {
74
+ auth.click();
75
+ auth.click();
76
+ });
77
+ expect(auth.disabled).toBe(true);
78
+ expect(mockedSend()).toHaveBeenCalledWith(
79
+ 'c1',
80
+ { type: 'moduleAction', actionId: 'member.request-alipay-authorization', contextToken: 'auth' },
81
+ );
82
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it('token 缺失 → 不自动触发,渲染手动授权按钮但禁用(无 token 点击无意义)', () => {
86
+ const { host } = render(
87
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={false} actionTokens={{ 'member.enroll': 'tok' }} />,
88
+ );
89
+ expect(mockedSend()).not.toHaveBeenCalled();
90
+ const btn = host.querySelector('.member-enrollment-form__auth-btn') as HTMLButtonElement;
91
+ expect(btn).not.toBeNull();
92
+ expect(btn.disabled).toBe(true);
93
+ });
94
+
95
+ it('isHistory → 全禁 + 不自动触发', () => {
96
+ const { host } = render(
97
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={true} actionTokens={{ 'member.enroll': 'tok', 'member.request-alipay-authorization': 'auth' }} />,
98
+ );
99
+ expect(mockedSend()).not.toHaveBeenCalled();
100
+ const controls = host.querySelectorAll('input, select');
101
+ controls.forEach((c) => expect((c as HTMLInputElement).disabled || (c as HTMLInputElement).readOnly).toBe(true));
102
+ const submit = host.querySelector('.member-ef-submit') as HTMLButtonElement;
103
+ expect(submit.disabled).toBe(true);
104
+ });
105
+
106
+ it('readOnly 字段渲染锁定图标', () => {
107
+ const { host } = render(
108
+ <MemberEnrollmentFormCard data={formData as never} cardId="c1" isHistory={false} actionTokens={{ 'member.enroll': 'tok' }} />,
109
+ );
110
+ expect(host.querySelector('.member-ef-field__lock')).not.toBeNull();
111
+ const mobile = host.querySelectorAll('.member-ef-field__control')[0] as HTMLInputElement;
112
+ expect(mobile.readOnly).toBe(true);
113
+ });
114
+
115
+ it('必填字段或必选协议缺失时原地提示,不发送入会动作;完整表单只提交一次', () => {
116
+ const { host } = render(
117
+ <MemberEnrollmentFormCard data={formData as never} cardId="c-submit" isHistory={false} actionTokens={{ 'member.enroll': 'tok' }} />,
118
+ );
119
+ const submit = host.querySelector('.member-ef-submit') as HTMLButtonElement;
120
+ act(() => submit.click());
121
+ expect(host.textContent).toContain('请填写姓名');
122
+ expect(host.textContent).toContain('请先同意:隐私政策');
123
+ expect(mockedSend()).not.toHaveBeenCalled();
124
+
125
+ mockedSend().mockClear();
126
+ const completeData = {
127
+ ...formData,
128
+ fields: formData.fields.map((field) => field.fieldId === 'name' ? { ...field, prefill: '张三' } : field),
129
+ consents: [],
130
+ };
131
+ const complete = render(
132
+ <MemberEnrollmentFormCard data={completeData as never} cardId="c-submit-complete" isHistory={false} actionTokens={{ 'member.enroll': 'tok' }} />,
133
+ );
134
+ const completeSubmit = complete.host.querySelector('.member-ef-submit') as HTMLButtonElement;
135
+ act(() => {
136
+ completeSubmit.click();
137
+ completeSubmit.click();
138
+ });
139
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
140
+ expect(mockedSend()).toHaveBeenCalledWith(
141
+ 'c-submit-complete',
142
+ { type: 'moduleAction', actionId: 'member.enroll', contextToken: 'tok' },
143
+ expect.objectContaining({ acceptedConsentIds: [], formVersion: 'v1' }),
144
+ );
145
+ });
146
+ });
147
+
148
+ describe('MemberEnrollmentCtaCard', () => {
149
+ beforeEach(() => vi.clearAllMocks());
150
+
151
+ it('通过 SDK openLink 打开真实地址,双击只发送一次且不展示静态权益', () => {
152
+ const { host } = render(
153
+ <MemberEnrollmentCtaCard
154
+ data={{ userId: 'u1', authState: 'authenticated', state: 'not_enrolled', enrollUrl: 'alipays://member/open', title: '入会后继续' }}
155
+ cardId="cta-1"
156
+ isHistory={false}
157
+ />,
158
+ );
159
+ const button = host.querySelector('.member-enroll-cta__btn') as HTMLButtonElement;
160
+ act(() => {
161
+ button.click();
162
+ button.click();
163
+ });
164
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
165
+ expect(mockedSend()).toHaveBeenCalledWith('cta-1', { type: 'openLink', url: 'alipays://member/open' });
166
+ expect(button.disabled).toBe(true);
167
+ expect(host.textContent).not.toMatch(/1000|免费停车|专属停车|生日服务|会员专线/);
168
+ });
169
+
170
+ it('历史 CTA 禁用且不触发跳转', () => {
171
+ const { host } = render(
172
+ <MemberEnrollmentCtaCard data={{ userId: 'u1', authState: 'authenticated', state: 'not_enrolled', enrollUrl: 'https://example.com/enroll' }} cardId="cta-history" isHistory />,
173
+ );
174
+ const button = host.querySelector('.member-enroll-cta__btn') as HTMLButtonElement;
175
+ expect(button.disabled).toBe(true);
176
+ act(() => button.click());
177
+ expect(mockedSend()).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it('无 enrollUrl(深链解耦形态)→ 按钮禁用、文案“暂不可用”且不发 openLink', () => {
181
+ const { host } = render(
182
+ <MemberEnrollmentCtaCard
183
+ data={{ userId: 'u1', authState: 'authenticated', state: 'not_enrolled' }}
184
+ cardId="cta-nourl"
185
+ isHistory={false}
186
+ />,
187
+ );
188
+ const button = host.querySelector('.member-enroll-cta__btn') as HTMLButtonElement;
189
+ expect(button.disabled).toBe(true);
190
+ expect(button.getAttribute('aria-label')).toBe('入会暂不可用(请联系商场客服)');
191
+ expect(host.textContent).toContain('暂不可用(请联系商场客服)');
192
+ act(() => button.click());
193
+ expect(mockedSend()).not.toHaveBeenCalled();
194
+ });
195
+ });
196
+
197
+ describe('MemberLoginCtaCard', () => {
198
+ beforeEach(() => vi.clearAllMocks());
199
+ const LOGIN_URL = 'alipays://platformapi/startapp?appId=demo&page=v/login/index';
200
+
201
+ it('通过 SDK openLink 跳转登录页,双击只发一次', () => {
202
+ const { host } = render(
203
+ <MemberLoginCtaCard data={{ loginUrl: LOGIN_URL }} cardId="login-1" isHistory={false} />,
204
+ );
205
+ const button = host.querySelector('.member-login-cta__btn') as HTMLButtonElement;
206
+ act(() => {
207
+ button.click();
208
+ button.click();
209
+ });
210
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
211
+ expect(mockedSend()).toHaveBeenCalledWith('login-1', { type: 'openLink', url: LOGIN_URL });
212
+ expect(button.disabled).toBe(true);
213
+ expect(host.textContent).toContain('登录后可解锁全部功能');
214
+ });
215
+
216
+ it('不承诺登录之后有什么——权益要等厂商返回才知道', () => {
217
+ const { host } = render(
218
+ <MemberLoginCtaCard data={{ loginUrl: LOGIN_URL }} cardId="login-2" isHistory={false} />,
219
+ );
220
+ // 说明文案只讲「解锁全部功能」,不点名任何一项权益
221
+ expect(host.textContent).not.toMatch(/积分|折扣|免费停车|会员权益/);
222
+ });
223
+
224
+ it('历史卡禁用且不触发跳转', () => {
225
+ const { host } = render(
226
+ <MemberLoginCtaCard data={{ loginUrl: LOGIN_URL }} cardId="login-3" isHistory />,
227
+ );
228
+ const button = host.querySelector('.member-login-cta__btn') as HTMLButtonElement;
229
+ expect(button.disabled).toBe(true);
230
+ act(() => button.click());
231
+ expect(mockedSend()).not.toHaveBeenCalled();
232
+ expect(host.textContent).toContain('历史卡片不可重复操作');
233
+ });
234
+
235
+ it('无 loginUrl(深链解耦形态)→ 按钮禁用、文案“暂不可用”且不发 openLink', () => {
236
+ const { host } = render(
237
+ <MemberLoginCtaCard data={{ title: '登录后继续' }} cardId="login-nourl" isHistory={false} />,
238
+ );
239
+ const button = host.querySelector('.member-login-cta__btn') as HTMLButtonElement;
240
+ expect(button.disabled).toBe(true);
241
+ expect(button.getAttribute('aria-label')).toBe('登录暂不可用(请联系商场客服)');
242
+ expect(host.textContent).toContain('暂不可用(请联系商场客服)');
243
+ act(() => button.click());
244
+ expect(mockedSend()).not.toHaveBeenCalled();
245
+ });
246
+ });
247
+
248
+ describe('MemberPointUsageListCard', () => {
249
+ it('流水双色类', () => {
250
+ const { host } = render(
251
+ <MemberPointUsageListCard
252
+ data={{
253
+ userId: 'u1',
254
+ balance: 100,
255
+ membershipLabel: '悦享卡',
256
+ items: [
257
+ { usageRecordRef: 'r1', title: '消费抵扣', amount: '-120', usedAt: '2026-08-19T10:30:00+08:00' },
258
+ { usageRecordRef: 'r2', title: '签到奖励', amount: '+50' },
259
+ ],
260
+ total: 2,
261
+ } as never}
262
+ />,
263
+ );
264
+ const rows = host.querySelectorAll('.member-pu__row b');
265
+ expect(host.textContent).toContain('悦享卡');
266
+ expect(host.textContent).toContain('最近积分记录');
267
+ expect(host.textContent).toContain('2026.08.19');
268
+ expect(host.textContent).not.toMatch(/金卡|银卡|铂金/);
269
+ expect(rows[0].className).toContain('member-pu__amount--spend');
270
+ expect(rows[1].className).toContain('member-pu__amount--income');
271
+ });
272
+ });
273
+
274
+ describe('MemberInfoCard', () => {
275
+ it('只显示用户资料,不暴露内部 memberRef,缺头像时使用正式占位', () => {
276
+ const { host } = render(
277
+ <MemberInfoCard
278
+ data={{
279
+ userId: 'u1',
280
+ authState: 'authenticated',
281
+ state: 'active',
282
+ displayName: '风清',
283
+ contactDisplay: '181****6886',
284
+ memberRef: 'member:internal-ref',
285
+ registeredAt: '2026-08-18T08:00:00+08:00',
286
+ labels: [{ labelRef: 'l1', name: '悦享会员' }],
287
+ }}
288
+ />,
289
+ );
290
+ expect(host.textContent).toContain('会员资料');
291
+ expect(host.textContent).toContain('181****6886');
292
+ expect(host.textContent).toContain('2026.08.18');
293
+ expect(host.textContent).toContain('悦享会员');
294
+ expect(host.textContent).not.toContain('member:internal-ref');
295
+ expect(host.querySelector('.member-info__avatar--fallback')).not.toBeNull();
296
+ expect(host.querySelector('img')).toBeNull();
297
+ });
298
+ });
299
+
300
+ describe('MemberEnrollmentStatusCard', () => {
301
+ beforeEach(() => vi.clearAllMocks());
302
+
303
+ it('pending 使用签发 token 刷新,双击只发送一次且不展示内部会员标识', () => {
304
+ const { host } = render(
305
+ <MemberEnrollmentStatusCard
306
+ data={{
307
+ userId: 'u1',
308
+ attemptRef: 'attempt-1',
309
+ state: 'pending_confirmation',
310
+ memberRef: 'member:internal-ref',
311
+ message: '厂商确认中,可稍后再刷新',
312
+ }}
313
+ cardId="status-1"
314
+ isHistory={false}
315
+ actionTokens={{ 'member.query-enrollment-status': 'refresh-token' }}
316
+ />,
317
+ );
318
+ expect(host.textContent).toContain('入会申请');
319
+ expect(host.textContent).toContain('厂商确认中');
320
+ expect(host.textContent).toContain('刷新状态');
321
+ expect(host.textContent).not.toContain('member:internal-ref');
322
+ const refresh = host.querySelector('.member-es__refresh') as HTMLButtonElement;
323
+ act(() => {
324
+ refresh.click();
325
+ refresh.click();
326
+ });
327
+ expect(mockedSend()).toHaveBeenCalledTimes(1);
328
+ expect(mockedSend()).toHaveBeenCalledWith(
329
+ 'status-1',
330
+ { type: 'moduleAction', actionId: 'member.query-enrollment-status', contextToken: 'refresh-token' },
331
+ { attemptRef: 'attempt-1' },
332
+ );
333
+ expect(refresh.disabled).toBe(true);
334
+ });
335
+
336
+ it('历史 pending 禁用刷新并给出当前状态查询提示', () => {
337
+ const { host } = render(
338
+ <MemberEnrollmentStatusCard
339
+ data={{ userId: 'u1', attemptRef: 'a1', state: 'pending_confirmation' }}
340
+ cardId="status-history"
341
+ isHistory
342
+ actionTokens={{ 'member.query-enrollment-status': 'refresh-token' }}
343
+ />,
344
+ );
345
+ expect((host.querySelector('.member-es__refresh') as HTMLButtonElement).disabled).toBe(true);
346
+ expect(host.textContent).toContain('历史申请不可刷新');
347
+ });
348
+ });
349
+
350
+ describe('MemberCardSummaryCard', () => {
351
+ beforeEach(() => {
352
+ vi.clearAllMocks();
353
+ });
354
+
355
+ it('优先渲染 C 端 CardPhoto;无真实权益时不渲染静态权益区,且不把 levelName 当会员等级展示', () => {
356
+ const { host } = render(
357
+ <MemberCardSummaryCard
358
+ data={{
359
+ userId: 'u1',
360
+ cardRef: 'card-1',
361
+ cardDisplay: '悦享卡',
362
+ status: 'active',
363
+ levelName: '金卡',
364
+ availablePoints: 300,
365
+ cardImageUrl: 'https://img.example-mall.cn/sp_mall/member-card.png',
366
+ }}
367
+ />,
368
+ );
369
+ const image = host.querySelector('.member-card-summary__face') as HTMLImageElement;
370
+ expect(image.src).toBe('https://img.example-mall.cn/sp_mall/member-card.png');
371
+ expect(image.loading).toBe('eager');
372
+ expect(host.textContent).toContain('300');
373
+ expect(host.querySelector('.member-card-summary__benefits')).toBeNull();
374
+ expect(host.textContent).not.toMatch(/专属停车|积分兑换|生日服务|会员专线/);
375
+ expect(host.textContent).not.toContain('积分明细');
376
+ expect(host.textContent).not.toContain('全部优惠券');
377
+ expect(host.textContent).not.toContain('金卡');
378
+ });
379
+
380
+ it('动态卡面渲染具体积分、消费提示、进度线和接口权益', async () => {
381
+ const { host } = render(
382
+ <MemberCardSummaryCard
383
+ data={{
384
+ userId: 'u1',
385
+ cardRef: 'card-dynamic',
386
+ cardDisplay: '金卡会员',
387
+ status: 'active',
388
+ levelName: '金卡',
389
+ availablePoints: 987,
390
+ cardImageUrl: 'https://assets.example.com/member/gold-card.png',
391
+ pointsRuleImageUrl: 'https://assets.example.com/member/points-rule.jpg',
392
+ presentation: { variant: 'gold', labelsEmbedded: true },
393
+ progress: {
394
+ daySpentCents: 12_345,
395
+ yearSpentCents: 500_000,
396
+ percent: 25,
397
+ message: '今日已消费123.45元,年累计已消费5000.00元',
398
+ },
399
+ benefits: [
400
+ {
401
+ benefitRef: 'b1',
402
+ name: '免费停车',
403
+ iconUrl: 'https://assets.example.com/member/parking.png',
404
+ description: '可免2小时',
405
+ },
406
+ { benefitRef: 'b2', name: '积分换礼', action: { kind: 'miniapp', url: 'alipays://member/points-mall' } },
407
+ ],
408
+ }}
409
+ cardId="card-dynamic"
410
+ />,
411
+ );
412
+ expect(host.querySelector('.member-card-summary__face--gold')).not.toBeNull();
413
+ expect(host.querySelector('.member-card-summary__face--embedded')).not.toBeNull();
414
+ expect(host.querySelector('.member-card-summary--gold')).not.toBeNull();
415
+ expect(host.textContent).not.toContain('我的积分');
416
+ expect(host.textContent).not.toContain('金卡会员');
417
+ expect(host.textContent).toContain('987');
418
+ expect(host.textContent).toContain('今日已消费123.45元,年累计已消费5000.00元');
419
+ expect(host.textContent).toContain('免费停车');
420
+ expect(host.textContent).toContain('可免2小时');
421
+ expect(host.querySelector('.member-card-summary__benefit-description')).not.toBeNull();
422
+ expect(host.textContent).toContain('积分换礼');
423
+ expect(host.textContent).not.toContain('专属停车');
424
+ expect(host.querySelector('.member-card-summary__benefit-icon-fallback')).not.toBeNull();
425
+ const progress = host.querySelector('[role="progressbar"]') as HTMLElement;
426
+ expect(progress.getAttribute('aria-valuenow')).toBe('25');
427
+ expect((progress.firstElementChild as HTMLElement).style.width).toBe('25%');
428
+ const benefitButton = host.querySelector('.member-card-summary__benefit-button') as HTMLButtonElement;
429
+ expect(benefitButton).not.toBeNull();
430
+ act(() => benefitButton.click());
431
+ expect(mockedSend()).toHaveBeenCalledWith('card-dynamic', {
432
+ type: 'openLink',
433
+ url: 'alipays://member/points-mall',
434
+ });
435
+ expect(document.querySelector('.member-miniapp-dialog')).toBeNull();
436
+ });
437
+
438
+ it('点击卡图内置积分规则区域展示近全屏弹层,支持 Escape 关闭', () => {
439
+ const { host } = render(
440
+ <MemberCardSummaryCard
441
+ data={{
442
+ userId: 'u1',
443
+ cardRef: 'card-rule',
444
+ cardDisplay: '金卡会员',
445
+ status: 'active',
446
+ availablePoints: 987,
447
+ pointsRuleImageUrl: 'https://assets.example.com/member/points-rule.jpg',
448
+ presentation: { variant: 'gold', labelsEmbedded: true },
449
+ }}
450
+ />,
451
+ );
452
+ const trigger = host.querySelector('.member-card-summary__rule-hotspot') as HTMLButtonElement;
453
+ expect(trigger.getAttribute('aria-label')).toBe('积分规则');
454
+ expect(trigger.textContent).toBe('');
455
+ expect(host.querySelector('.member-card-summary__rule-trigger')).toBeNull();
456
+
457
+ act(() => { trigger.click(); });
458
+ const dialog = document.body.querySelector('.member-points-rule') as HTMLElement;
459
+ expect(trigger.className).toContain('member-card-summary__rule-hotspot');
460
+ expect(dialog.parentElement).toBe(document.body);
461
+ expect(host.contains(dialog)).toBe(false);
462
+ expect(dialog.getAttribute('role')).toBe('dialog');
463
+ expect(dialog.getAttribute('aria-modal')).toBe('true');
464
+ expect((dialog.querySelector('.member-points-rule__image') as HTMLImageElement).src)
465
+ .toBe('https://assets.example.com/member/points-rule.jpg');
466
+
467
+ act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
468
+ expect(dialog.className).toContain('member-points-rule--closing');
469
+ act(() => { dialog.dispatchEvent(new Event('animationend', { bubbles: true })); });
470
+ expect(document.body.querySelector('.member-points-rule')).toBeNull();
471
+ });
472
+
473
+ it('渲染 PRD 三项偏好,点击发送新的可见 query', () => {
474
+ const { host } = render(
475
+ <MemberCardSummaryCard
476
+ data={{
477
+ userId: 'u1',
478
+ cardRef: 'card-preferences',
479
+ cardDisplay: '悦享卡',
480
+ status: 'active',
481
+ availablePoints: 300,
482
+ }}
483
+ cardId="card-1"
484
+ isHistory={false}
485
+ />,
486
+ );
487
+ const buttons = [...host.querySelectorAll('.member-card-summary__preference-list button')] as HTMLButtonElement[];
488
+ expect(host.textContent).toContain('说说你的偏好');
489
+ expect(host.querySelector('.member-card-summary__preference-hint')?.textContent).toBe('请选择你的偏好,或直接告诉我');
490
+ expect(buttons.map((button) => button.textContent)).toEqual(['我常来买衣服', '我常带娃来玩', '我常来吃美食']);
491
+
492
+ act(() => { buttons[0].click(); });
493
+ expect(mockedSend()).toHaveBeenCalledWith('card-1', {
494
+ type: 'sendMessage',
495
+ value: '我常来买衣服',
496
+ displayText: '我常来买衣服',
497
+ visible: true,
498
+ });
499
+ });
500
+
501
+ it('历史会员卡的偏好按钮不可点击', () => {
502
+ const { host } = render(
503
+ <MemberCardSummaryCard
504
+ data={{
505
+ userId: 'u1',
506
+ cardRef: 'card-history',
507
+ cardDisplay: '悦享卡',
508
+ status: 'active',
509
+ availablePoints: 300,
510
+ }}
511
+ cardId="card-history"
512
+ isHistory
513
+ />,
514
+ );
515
+ const buttons = [...host.querySelectorAll('.member-card-summary__preference-list button')] as HTMLButtonElement[];
516
+ expect(buttons.every((button) => button.disabled)).toBe(true);
517
+ });
518
+ });
519
+
520
+ describe('D10 置位守卫(三轮修复:防"声明置位、实现漏置"回归)', () => {
521
+ it('member 三张自持结构卡 default 导出 selfRendersActions 置位', () => {
522
+ expect((MemberEnrollmentFormCardDefault as { selfRendersActions?: boolean }).selfRendersActions).toBe(true);
523
+ expect((MemberEnrollmentStatusCardDefault as { selfRendersActions?: boolean }).selfRendersActions).toBe(true);
524
+ expect((MemberCardSummaryCardDefault as { selfRendersActions?: boolean }).selfRendersActions).toBe(true);
525
+ });
526
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 015 S4:入会五阶段 mock 闭环(授权 effect → complete-auth → PII 暂存 → enroll → 状态刷新)。
3
+ * 覆盖:授权成功预填 / 授权降级 manual / PII TTL 与用后即删(F2)/ 幂等重入 / pending 刷新收口(R4)。
4
+ */
5
+ import { describe, expect, it } from 'vitest';
6
+ import { createInMemoryStateStore } from '@tbox.cn/app-sdk/server';
7
+ import {
8
+ createMemberActions,
9
+ MEMBER_PROFILE_NAMESPACE,
10
+ buildEnrollmentIdempotencyKey,
11
+ ENROLLMENT_FORM_VERSION,
12
+ } from '../src/server/action';
13
+ import { InMemoryMemberService } from '../src/server/service';
14
+ import type { RequestContext } from '@tbox.cn/app-contracts';
15
+
16
+ const member = new InMemoryMemberService();
17
+ const state = createInMemoryStateStore();
18
+
19
+ // ServerContext 最小 fake(动作只用 ctx.state)
20
+ const ctxFake = { state } as never;
21
+ const actions = createMemberActions(ctxFake, async () => member);
22
+ const reqCtx = (userId = 'u_1'): RequestContext =>
23
+ ({ identity: { userId, source: 'dev' }, attributes: { mall: { miniAppId: 'app', mallId: 'mall-1', subjectId: 's1' } } }) as never;
24
+
25
+ describe('入会链:授权 → 预填 → 提交 → 刷新', () => {
26
+ it('阶段 1:request-alipay-authorization 返回 authorization effect(resultActionId 指向回调动作)', async () => {
27
+ const result = await actions.requestAuthorization.execute!({}, reqCtx());
28
+ expect(result.effects).toEqual([
29
+ { type: 'authorization.alipay-user-profile', scopes: ['auth_user'], resultActionId: 'member.complete-alipay-authorization' },
30
+ ]);
31
+ });
32
+
33
+ it('阶段 2:complete-auth success + fake PII 暂存 → 预填表单卡(mobile readOnly)', async () => {
34
+ // 不打真实支付宝(env 未配置)→ 模拟授权失败路径先行验证 manual 降级
35
+ const manual = await actions.completeAuthorization.execute!({ status: 'cancel' }, reqCtx());
36
+ expect(manual.card?.cardType).toBe('member-enrollment-form');
37
+ expect((manual.card?.data as { manualNotice?: string }).manualNotice).toBeTruthy();
38
+
39
+ // success + authCode(ALIPAY 未配置 → 降级 manual;此处直接预置 PII 验证 enroll 消费路径)
40
+ const stored = { mobile: '13800001234', displayName: '张三' };
41
+ await state.put(MEMBER_PROFILE_NAMESPACE, 'u_2', stored, 30 * 60 * 1000);
42
+ const enroll = await actions.enroll.execute!(
43
+ { fieldValues: { mobile: '15999999999' }, acceptedConsentIds: ['privacy'] },
44
+ reqCtx('u_2'),
45
+ );
46
+ // 授权 mobile 强制覆盖手填(pending 卡返回 + PII 已消费)
47
+ expect((enroll.card?.data as { state: string }).state).toBe('pending_confirmation');
48
+ // F2:PII 用后即删(consume 语义)
49
+ expect(await state.get(MEMBER_PROFILE_NAMESPACE, 'u_2')).toBeNull();
50
+ // 手机号以授权值入库:手动改用授权 mobile 的记录可通过 profile 验证(pending 后刷新 active)
51
+ const attemptRef = (enroll.card?.data as { attemptRef?: string }).attemptRef;
52
+ const first = await actions.queryEnrollmentStatus.execute!({ attemptRef }, reqCtx('u_2'));
53
+ expect((first.update?.data as { state: string }).state).toBe('pending_confirmation');
54
+ const second = await actions.queryEnrollmentStatus.execute!({ attemptRef }, reqCtx('u_2'));
55
+ expect((second.update?.data as { state: string }).state).toBe('active');
56
+ });
57
+
58
+ it('阶段 3:enroll 必选 consent 未勾 → 拒绝', async () => {
59
+ const result = await actions.enroll.execute!({ fieldValues: { mobile: '13800001234' }, acceptedConsentIds: [] }, reqCtx('u_3'));
60
+ expect((result.card?.data as { state: string }).state).toBe('failed');
61
+ expect((result.card?.data as { message?: string }).message).toContain('隐私政策');
62
+ });
63
+
64
+ it('阶段 4:enroll manual 手机号非法 → 拒绝', async () => {
65
+ const result = await actions.enroll.execute!({ fieldValues: { mobile: '123' }, acceptedConsentIds: ['privacy'] }, reqCtx('u_4'));
66
+ expect((result.card?.data as { state: string }).state).toBe('failed');
67
+ expect((result.card?.data as { message?: string }).message).toContain('手机号');
68
+ });
69
+
70
+ it('阶段 5:pending → 显式刷新(两次查询后 active,R4 无自动轮询)', async () => {
71
+ const enroll = await actions.enroll.execute!(
72
+ { fieldValues: { mobile: '13800005678' }, acceptedConsentIds: ['privacy'] },
73
+ reqCtx('u_5'),
74
+ );
75
+ const attemptRef = (enroll.card?.data as { attemptRef?: string }).attemptRef;
76
+ expect(attemptRef).toBeTruthy();
77
+
78
+ const first = await actions.queryEnrollmentStatus.execute!({ attemptRef }, reqCtx('u_5'));
79
+ expect((first.update?.data as { state: string }).state).toBe('pending_confirmation');
80
+
81
+ const second = await actions.queryEnrollmentStatus.execute!({ attemptRef }, reqCtx('u_5'));
82
+ expect((second.update?.data as { state: string }).state).toBe('active');
83
+ });
84
+
85
+ it('幂等键:同 userId+mallId+formVersion 恒定(sha256 双 \\0 分隔)', () => {
86
+ const k1 = buildEnrollmentIdempotencyKey('u_1', 'mall-1', ENROLLMENT_FORM_VERSION);
87
+ const k2 = buildEnrollmentIdempotencyKey('u_1', 'mall-1', ENROLLMENT_FORM_VERSION);
88
+ const k3 = buildEnrollmentIdempotencyKey('u_1', 'mall-2', ENROLLMENT_FORM_VERSION);
89
+ expect(k1).toBe(k2);
90
+ expect(k1).not.toBe(k3);
91
+ expect(k1).toHaveLength(64);
92
+ });
93
+
94
+ it('动作声明:complete-alipay-authorization 为 hidden(不渲染按钮,补签通道专用)', () => {
95
+ expect(actions.completeAuthorization.hidden).toBe(true);
96
+ expect(actions.completeAuthorization.singleUse).toBe(true);
97
+ expect(actions.requestAuthorization.singleUse).toBe(true);
98
+ });
99
+
100
+ it('评审修复(#3):厂商异常释放幂等占位 → 重试可成功(不固化 24h 拒绝)', async () => {
101
+ const flaky = new InMemoryMemberService();
102
+ const origEnroll = flaky.enroll.bind(flaky);
103
+ let failNext = true;
104
+ flaky.enroll = async (input: Parameters<InMemoryMemberService['enroll']>[0]) => {
105
+ if (failNext) throw new Error('gateway timeout');
106
+ return origEnroll(input);
107
+ };
108
+ const flakyActions = createMemberActions(ctxFake, async () => flaky);
109
+ const payload = { fieldValues: { mobile: '13800009999' }, acceptedConsentIds: ['privacy'] };
110
+
111
+ // 第一次:厂商异常 → failed + 提示重试(占位已释放)
112
+ const first = await flakyActions.enroll.execute!(payload, reqCtx('u_retry'));
113
+ expect((first.card?.data as { state: string }).state).toBe('failed');
114
+ expect((first.card?.data as { message?: string }).message).toContain('请稍后重试');
115
+
116
+ // 第二次:恢复 → 占位不阻塞,正常受理
117
+ failNext = false;
118
+ const second = await flakyActions.enroll.execute!(payload, reqCtx('u_retry'));
119
+ expect((second.card?.data as { state: string }).state).toBe('pending_confirmation');
120
+ });
121
+ });