@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
@@ -1,53 +1,614 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { createServerContext } from '@tbox.cn/app-sdk/server';
1
+ /**
2
+ * module-member 工具链回归(F5 终态):槽位轴解析 → 自包含 stub adapter 产出
3
+ * 完整 MemberService 面;锁定工具投影、卡片决策与上下文服务行为。
4
+ * 上下文统一化:显式缝——工具 execute 第二参 = createToolExecContext(reqCtx),
5
+ * 跨模块消费走 'member.service' 工厂(替代旧 ctx.auth 全局单例假缝)。
6
+ */
7
+ import { describe, expect, it, vi } from 'vitest';
8
+
9
+ // 模块级 mock:resolveMemberStatus 默认转发真实实现(其余用例不感知);
10
+ // 仅 handler「active 缺服务实例」防御分支用例经 mockResolvedValueOnce 构造
11
+ // 真实 resolver 类型上不可产出的形态(active ⟹ service 必在)
12
+ vi.mock('../src/server/status', async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import('../src/server/status')>();
14
+ return { ...actual, resolveMemberStatus: vi.fn(actual.resolveMemberStatus) };
15
+ });
16
+
17
+ import type { RequestContext } from '@tbox.cn/app-contracts';
18
+ import {
19
+ createIntegrationsRegistry,
20
+ createAuthStrategyRegistry,
21
+ createEnvironmentSecretProvider,
22
+ createServerContext,
23
+ createToolExecContext,
24
+ } from '@tbox.cn/app-sdk/server';
25
+ import type { ToolExecContextLike, ServerContext } from '@tbox.cn/app-sdk/server';
26
+ import type { MemberAccountPort, MemberBenefitsFactory, MemberStatusFactory } from '@tbox.cn/app-contracts-mall';
27
+ import type { MemberService } from '../src/server/service';
3
28
  import { serverModule } from '../src/server/index';
29
+ import { createMemberHandler } from '../src/server/handler';
30
+ import { resolveMemberStatus } from '../src/server/status';
31
+ import { memberCardSummaryMeta } from '../src/server/cards';
32
+ import {
33
+ MEMBER_PRIVACY_REFUSAL,
34
+ createQueryEnrollmentCtaTool,
35
+ createQueryLoginCtaTool,
36
+ createQueryMemberCardTool,
37
+ createQueryMemberInfoTool,
38
+ createQueryPointsTool,
39
+ } from '../src/server/tool';
4
40
 
5
- describe('module-member 自注册与积分服务', () => {
6
- it('serverModule.register 注册 tool/card/service/handler', () => {
7
- const ctx = createServerContext();
8
- serverModule.register(ctx);
9
41
 
10
- expect(ctx.tools.has('queryPoints')).toBe(true);
11
- expect(ctx.cards.has('points')).toBe(true);
42
+ /** fat stub getter → 五槽 ports 注入(017 槽位原子化测试基建:stub 实现全部 Port 方法,
43
+ * 结构兼容任一槽产物视图)。 */
44
+ function memberPortsOf(getStub: () => Promise<MemberService>): import('../src/server/tool').MemberToolPorts {
45
+ return {
46
+ getAccount: getStub,
47
+ getProfile: getStub,
48
+ getCard: getStub,
49
+ getBenefits: getStub,
50
+ getEnrollment: getStub,
51
+ };
52
+ }
53
+
54
+ function requestContext(mallId = 'mall-demo', token = 'user-token', provider = 'dev'): RequestContext {
55
+ return {
56
+ identity: { userId: 'u_1001', source: 'dev' },
57
+ credentials: { provider, token },
58
+ attributes: { mall: { miniAppId: 'demo-app', mallId } },
59
+ };
60
+ }
61
+
62
+ /** 自包含 stub:登录状态与会员状态分轴。 */
63
+ function stubMemberService(userToken: string, completeCard = false): MemberService {
64
+ const now = new Date().toISOString();
65
+ return {
66
+ enrollUrl: 'alipays://platformapi/startapp?appId=demo',
67
+ async getAccount() {
68
+ if (!userToken) {
69
+ return { authState: 'unauthenticated', state: 'unknown', observedAt: now };
70
+ }
71
+ return { authState: 'authenticated', state: 'active', memberRef: 'member:C-1001', primaryMallCardNo: 'C-1001', observedAt: now };
72
+ },
73
+ async getProfile() {
74
+ return {
75
+ displayName: '测试会员',
76
+ contactDisplay: '138****0000',
77
+ labels: [{ labelRef: 'member', name: '悦享卡' }],
78
+ observedAt: now,
79
+ };
80
+ },
81
+ async getCard() {
82
+ return {
83
+ cardRef: 'card:C-1001',
84
+ cardDisplay: '悦享卡',
85
+ status: 'active',
86
+ cardImageUrl: 'https://img.example-mall.cn/sp_mall/member-card.png',
87
+ ...(completeCard ? { presentation: { variant: 'blue' as const } } : {}),
88
+ observedAt: now,
89
+ };
90
+ },
91
+ async queryPoints(userId) {
92
+ return { userId, balance: 300, membershipLabel: '悦享卡' };
93
+ },
94
+ async getBenefits() {
95
+ return { availablePoints: 300, observedAt: now };
96
+ },
97
+ async listPointUsage() {
98
+ return {
99
+ items: [
100
+ {
101
+ usageRecordRef: 'usage:1',
102
+ title: '促销奖励',
103
+ amount: '+300',
104
+ usedAt: '2026-08-14T03:05:25.000Z',
105
+ description: '开卡礼',
106
+ },
107
+ ],
108
+ total: 1,
109
+ };
110
+ },
111
+ async queryDiscount() {
112
+ return undefined;
113
+ },
114
+ async deductPoints() {
115
+ return false;
116
+ },
117
+ async refundPoints() {
118
+ return false;
119
+ },
120
+ async awardPoints() {
121
+ return false;
122
+ },
123
+ async getEnrollmentFormDefinition() {
124
+ return {
125
+ formVersion: 'fixture-9-2',
126
+ fields: [{ fieldId: 'mobile', label: '手机号', type: 'tel', required: true, placeholder: '请输入手机号' }],
127
+ consents: [{ consentId: 'privacy', version: '2', title: '隐私政策', required: true }],
128
+ authorizationStatus: 'required',
129
+ };
130
+ },
131
+ async enroll(input) {
132
+ return { status: 'pending_confirmation', attemptRef: input.idempotencyKey };
133
+ },
134
+ async getEnrollmentStatus() {
135
+ return { status: 'active', memberRef: 'member:C-1001' };
136
+ },
137
+ } satisfies MemberService;
138
+ }
139
+
140
+ const FIXTURE_PROVIDER = 'fixture';
141
+ const FIXTURE_PROVIDER_ID = 'fixture-member@1';
142
+
143
+ function fixtureServer(mallId = 'mall-demo', token = 'user-token'): ServerContext {
144
+ const registry = createIntegrationsRegistry({
145
+ integrations: {
146
+ services: {
147
+ 'member.account': { provider: FIXTURE_PROVIDER, implementation: FIXTURE_PROVIDER_ID, instances: { [mallId]: { enabled: true } } },
148
+ 'member.profile': { provider: FIXTURE_PROVIDER, implementation: FIXTURE_PROVIDER_ID, instances: { [mallId]: { enabled: true } } },
149
+ 'member.card': { provider: FIXTURE_PROVIDER, implementation: FIXTURE_PROVIDER_ID, instances: { [mallId]: { enabled: true } } },
150
+ 'member.benefits': { provider: FIXTURE_PROVIDER, implementation: FIXTURE_PROVIDER_ID, instances: { [mallId]: { enabled: true } } },
151
+ 'member.enrollment': { provider: FIXTURE_PROVIDER, implementation: FIXTURE_PROVIDER_ID, instances: { [mallId]: { enabled: true } } },
152
+ },
153
+ },
154
+ catalog: {
155
+ providers: {
156
+ // local: true → transport 短路(stub 不消费 transport,免凭据/认证链)
157
+ [FIXTURE_PROVIDER]: {
158
+ [FIXTURE_PROVIDER_ID]: { provider: FIXTURE_PROVIDER, implementation: FIXTURE_PROVIDER_ID, credentialType: '-', owner: 'fixture', services: ['member.account', 'member.profile', 'member.card', 'member.benefits', 'member.enrollment'], local: true },
159
+ },
160
+ },
161
+ services: {},
162
+ credentialTypes: {},
163
+ },
164
+ authStrategies: createAuthStrategyRegistry(),
165
+ secretProvider: createEnvironmentSecretProvider({ source: {}, production: false }),
166
+ instanceIdOf: (scope) => scope.mallId ?? '',
167
+ });
168
+ registry.registerAdapter(FIXTURE_PROVIDER, FIXTURE_PROVIDER_ID, () => stubMemberService(token));
169
+ const ctx = createServerContext({ integrations: registry });
170
+ serverModule.register(ctx);
171
+ return ctx;
172
+ }
173
+
174
+ /** 显式缝:工具 execute 第二参(reqCtx 散键写入与引擎 buildRunRequestContext 同构) */
175
+ function execCtx(mallId = 'mall-demo', token = 'user-token'): ToolExecContextLike {
176
+ return createToolExecContext(requestContext(mallId, token));
177
+ }
178
+
179
+ describe('module-member 槽位轴工具链', () => {
180
+ it('注册五个工具、七张卡、动作、handler 和上下文服务', () => {
181
+ const ctx = fixtureServer();
182
+ expect(
183
+ ['queryPoints', 'queryMemberInfo', 'queryMemberCard', 'queryEnrollmentForm', 'queryEnrollmentCta'].every((id) =>
184
+ ctx.tools.has(id),
185
+ ),
186
+ ).toBe(true);
187
+ expect(ctx.tools.resolve('queryPoints')?.description).not.toContain('等级');
188
+ expect(ctx.tools.resolve('queryMemberCard')?.description).not.toContain('等级');
189
+ expect(ctx.cards.has('member-point-usage-list')).toBe(true);
190
+ expect(ctx.cards.has('member-info')).toBe(true);
191
+ expect(ctx.cards.has('member-card-summary')).toBe(true);
192
+ expect(ctx.cards.has('member-enrollment-form')).toBe(true);
193
+ expect(ctx.cards.has('member-enrollment-status')).toBe(true);
194
+ expect(ctx.cards.has('member-enrollment-cta')).toBe(true);
195
+ expect(ctx.cards.has('member-login-cta')).toBe(true);
196
+ expect(ctx.cardActions.get('member.enroll')).toBeDefined();
12
197
  expect(ctx.handlers.has('member')).toBe(true);
13
- expect(ctx.resolveService('member')).toBeDefined();
198
+ expect(ctx.resolveService<MemberBenefitsFactory>('member.service')).toBeDefined();
199
+ expect(ctx.resolveService<MemberStatusFactory>('member.status')).toBeDefined();
14
200
  });
15
201
 
16
- it('queryPoints 返回初始积分', async () => {
17
- const ctx = createServerContext();
18
- serverModule.register(ctx);
19
- const member = ctx.resolveService<{ queryPoints(u: string): Promise<{ balance: number; level: string }> }>('member')!;
20
- const points = await member.queryPoints('u_1001');
21
- expect(points.balance).toBe(860);
22
- expect(points.level).toBe('gold');
202
+ it('会员卡契约升至 v4,并以 identity migrate 兼容旧快照', () => {
203
+ const oldData = {
204
+ userId: 'u_1001',
205
+ cardRef: 'card:C-1001',
206
+ cardDisplay: '悦享卡',
207
+ status: 'active',
208
+ availablePoints: 300,
209
+ };
210
+ expect(memberCardSummaryMeta.schemaVersion).toBe(5);
211
+ expect(memberCardSummaryMeta.migrate?.(oldData, 1)).toEqual(oldData);
212
+ expect(memberCardSummaryMeta.dataSchema.safeParse(oldData).success).toBe(true);
23
213
  });
24
214
 
25
- it('deductPoints 幂等(同 idempotencyKey 只扣一次)', async () => {
26
- const ctx = createServerContext();
27
- serverModule.register(ctx);
28
- const member = ctx.resolveService<{ deductPoints(u: string, a: number, k: string): Promise<boolean> }>('member')!;
215
+ it('queryMemberCard 将动态卡面、进度和真实权益投影到出卡数据', async () => {
216
+ const tool = createQueryMemberCardTool(memberPortsOf(async () => ({
217
+ ...stubMemberService('user-token'),
218
+ async getCard() {
219
+ return {
220
+ cardRef: 'card:dynamic',
221
+ cardDisplay: '金卡会员',
222
+ status: 'active' as const,
223
+ pointsRuleImageUrl: 'https://assets.example.com/member/points-rule.jpg',
224
+ presentation: { variant: 'gold' as const, labelsEmbedded: true },
225
+ progress: {
226
+ daySpentCents: 12_345,
227
+ yearSpentCents: 500_000,
228
+ percent: 25,
229
+ message: '今日已消费123.45元,年累计已消费5000.00元',
230
+ },
231
+ benefits: [{ benefitRef: 'benefit:points', name: '消费积分' }],
232
+ observedAt: '2026-08-25T00:00:00.000Z',
233
+ };
234
+ },
235
+ async queryPoints(userId: string) {
236
+ return { userId, balance: 987 };
237
+ },
238
+ })));
239
+ const result = await (tool as unknown as {
240
+ execute(input: unknown, execCtx?: ToolExecContextLike): Promise<{ cards: Array<{ data: Record<string, unknown> }> }>;
241
+ }).execute({}, execCtx());
242
+ expect(result.cards[0]?.data).toMatchObject({
243
+ presentation: { variant: 'gold', labelsEmbedded: true },
244
+ pointsRuleImageUrl: 'https://assets.example.com/member/points-rule.jpg',
245
+ progress: { percent: 25 },
246
+ benefits: [{ benefitRef: 'benefit:points', name: '消费积分' }],
247
+ contractNotice: expect.stringContaining('仅展示系统返回的当前等级权益'),
248
+ });
249
+ });
29
250
 
30
- expect(await member.deductPoints('u_1001', 100, 'key-1')).toBe(true);
31
- expect(await member.deductPoints('u_1001', 100, 'key-1')).toBe(true); // 幂等
32
- const after = await ctx.resolveService<{ queryPoints(u: string): Promise<{ balance: number }> }>('member')!.queryPoints('u_1001');
33
- expect(after.balance).toBe(760); // 只扣一次
251
+ it('跨模块 MemberBenefitsFactory 解析后按 reqCtx 调用读取余额真值', async () => {
252
+ const ctx = fixtureServer();
253
+ const factory = ctx.resolveService<MemberBenefitsFactory>('member.service');
254
+ const member = await factory?.(requestContext());
255
+ await expect(member?.queryPoints('u_1001')).resolves.toMatchObject({ balance: 300, membershipLabel: '悦享卡' });
34
256
  });
35
257
 
36
- it('余额不足扣减失败', async () => {
37
- const ctx = createServerContext();
38
- serverModule.register(ctx);
39
- const member = ctx.resolveService<{ deductPoints(u: string, a: number, k: string): Promise<boolean> }>('member')!;
40
- expect(await member.deductPoints('u_1001', 99999, 'key-x')).toBe(false);
258
+ it('跨模块会员工厂解析后暴露账户状态,供停车卡按真实状态分流', async () => {
259
+ const ctx = fixtureServer();
260
+ const factory = ctx.resolveService<MemberBenefitsFactory>('member.service');
261
+ const member = (await factory?.(requestContext())) as MemberAccountPort | undefined;
262
+ await expect(member?.getAccount('u_1001')).resolves.toMatchObject({ state: 'active' });
263
+ });
264
+
265
+ it('queryPoints 对明确积分查询只投影会员卡面,流水供 LLM 消费', async () => {
266
+ const ctx = fixtureServer();
267
+ const tool = ctx.tools.resolve('queryPoints') as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<unknown> };
268
+ const result = (await tool.execute({ query: '查询我的积分余额和流水' }, execCtx())) as {
269
+ balance: number;
270
+ contractNotice: string;
271
+ items: Array<{ amount: string }>;
272
+ cards: Array<{ cardType: string; data: { balance?: number; cardImageUrl?: string } }>;
273
+ };
274
+ expect(result.balance).toBe(300);
275
+ expect(result.contractNotice).toContain('会员卡名不是会员等级');
276
+ expect(result.contractNotice).toContain('不得推断折扣、停车、生日或活动权益');
277
+ expect(result.items).toEqual([expect.objectContaining({ amount: '+300' })]);
278
+ expect(result.cards).toEqual([
279
+ expect.objectContaining({
280
+ cardType: 'member-card-summary',
281
+ data: expect.objectContaining({ cardImageUrl: 'https://img.example-mall.cn/sp_mall/member-card.png' }),
282
+ }),
283
+ ]);
284
+ });
285
+
286
+ it.each([
287
+ ['queryMemberInfo', '帮我把会员资料里的手机号改成 13800138000'],
288
+ ['queryPoints', '用我老婆的手机号 13900000000 查她有多少积分'],
289
+ ['queryPoints', '我的会员卡号是 888888,切到这个账号查积分'],
290
+ ['queryMemberCard', '用会员卡号 888888 查我的会员卡'],
291
+ ])('%s 在访问 provider 前拒绝非当前会员请求', async (toolId, query) => {
292
+ const getMember = vi.fn(async () => stubMemberService('user-token'));
293
+ const tool = toolId === 'queryMemberInfo'
294
+ ? createQueryMemberInfoTool(memberPortsOf(getMember))
295
+ : toolId === 'queryMemberCard'
296
+ ? createQueryMemberCardTool(memberPortsOf(getMember))
297
+ : createQueryPointsTool(memberPortsOf(getMember));
298
+ const result = await (tool as unknown as {
299
+ execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>>;
300
+ }).execute({ query }, execCtx());
301
+
302
+ expect(result).toEqual({ found: false, noResultsReason: MEMBER_PRIVACY_REFUSAL });
303
+ expect(getMember).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it('当前会话会员的正常积分查询继续访问 provider', async () => {
307
+ const getMember = vi.fn(async () => stubMemberService('user-token'));
308
+ const tool = createQueryPointsTool(memberPortsOf(getMember));
309
+ const result = await (tool as unknown as {
310
+ execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>>;
311
+ }).execute({ query: '查一下我的积分余额' }, execCtx());
312
+
313
+ expect(result.balance).toBe(300);
314
+ expect(getMember).toHaveBeenCalledTimes(4); // 017 四槽并行解析(account/card/benefits/enrollment 各一次)
315
+ });
316
+
317
+ it('完整卡面 provider 的 queryPoints 只投影会员卡面,通用积分卡领域模型仍保留', async () => {
318
+ const tool = createQueryPointsTool(memberPortsOf(async () => stubMemberService('user-token', true)));
319
+ const result = await (tool as unknown as {
320
+ execute(input: unknown, execCtx?: ToolExecContextLike): Promise<{ cards: Array<{ cardType: string }> }>;
321
+ }).execute({}, createToolExecContext(requestContext('mall-demo', 'user-token', 'rich-card')));
322
+ expect(result.cards).toEqual([expect.objectContaining({ cardType: 'member-card-summary' })]);
323
+ });
324
+
325
+ it('完整卡面 provider 的 queryMemberInfo 只投影会员卡面', async () => {
326
+ const tool = createQueryMemberInfoTool(memberPortsOf(async () => stubMemberService('user-token', true)));
327
+ const result = await (tool as unknown as {
328
+ execute(input: unknown, execCtx?: ToolExecContextLike): Promise<{ cards: Array<{ cardType: string }> }>;
329
+ }).execute({}, createToolExecContext(requestContext('mall-demo', 'user-token', 'rich-card')));
330
+ expect(result.cards).toEqual([expect.objectContaining({ cardType: 'member-card-summary' })]);
331
+ });
332
+
333
+ it('会员查询以卡面为唯一积分主卡,流水不进入候选卡池', async () => {
334
+ const ctx = fixtureServer();
335
+ const execute = async (toolId: string, input: unknown) => {
336
+ const tool = ctx.tools.resolve(toolId) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
337
+ return tool.execute(input, execCtx());
338
+ };
339
+
340
+ const points = await execute('queryPoints', { query: '查询我的积分余额和流水' });
341
+ expect(points.cards).toEqual([expect.objectContaining({ cardType: 'member-card-summary' })]);
342
+
343
+ const info = await execute('queryMemberInfo', {});
344
+ expect(info.cards).toEqual([
345
+ expect.objectContaining({ cardType: 'member-card-summary' }),
346
+ expect.objectContaining({ cardType: 'member-info' }),
347
+ ]);
348
+
349
+ const card = await execute('queryMemberCard', {});
350
+ expect(card.cards).toEqual([expect.objectContaining({ cardType: 'member-card-summary' })]);
351
+
352
+ const enrollment = await execute('queryEnrollmentForm', {});
353
+ expect(enrollment).toMatchObject({ state: 'active', found: false });
354
+ expect(enrollment).not.toHaveProperty('cards');
355
+ });
356
+
357
+ it('numeric-looking member IDs stay strings through member card/info tools', async () => {
358
+ const ctx = fixtureServer();
359
+ const execute = async (toolId: string, input: unknown) => {
360
+ const tool = ctx.tools.resolve(toolId) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
361
+ return tool.execute(input, execCtx());
362
+ };
363
+ // 上下文统一化:userId 来自运行身份(reqCtx.identity.userId)不再经 LLM 传参——
364
+ // 数值形态字符串由 requestContext 的 userId 承载(注入面关闭后该用例锁「字符串不数值化」)
365
+ const numericCtx = createToolExecContext({
366
+ identity: { userId: '17000001019327925', source: 'dev' },
367
+ attributes: { mall: { miniAppId: 'demo-app', mallId: 'mall-demo' } },
368
+ });
369
+ const tool = ctx.tools.resolve('queryMemberInfo') as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
370
+ await expect(tool.execute({}, numericCtx)).resolves.toMatchObject({
371
+ userId: '17000001019327925',
372
+ });
373
+ const cardTool = ctx.tools.resolve('queryMemberCard') as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
374
+ await expect(cardTool.execute({}, numericCtx)).resolves.toMatchObject({
375
+ userId: '17000001019327925',
376
+ });
377
+ void execute;
41
378
  });
42
379
 
43
- it('parking.coupon.used 事件 → 奖积分(跨模块解耦)', async () => {
380
+ it('integrations 缺失时明确失败(F5 终态:唯一供给轴缺席即报错)', async () => {
44
381
  const ctx = createServerContext();
45
382
  serverModule.register(ctx);
46
- // 模拟 parking 发布事件
47
- ctx.bus.emit('parking.coupon.used', { userId: 'u_1002', couponId: 'c_1', amount: 50 });
48
- await new Promise((r) => setTimeout(r, 10)); // 等待异步订阅
49
- const member = ctx.resolveService<{ queryPoints(u: string): Promise<{ balance: number }> }>('member')!;
50
- const after = await member.queryPoints('u_1002');
51
- expect(after.balance).toBe(120 + Math.floor(50 / 10));
383
+ const factory = ctx.resolveService<MemberBenefitsFactory>('member.service');
384
+ await expect(factory?.(requestContext('mall-without-provider'))).rejects.toThrow(/integrations registry 缺失/);
385
+ });
386
+
387
+ it('queryEnrollmentCta 未登录且未配登录入口:出登录卡(无 URL 字段,深链解耦后恒出卡)', async () => {
388
+ const ctx = fixtureServer('10025', '');
389
+ const tool = ctx.tools.resolve('queryEnrollmentCta') as unknown as {
390
+ execute(input: unknown, execCtx?: ToolExecContextLike): Promise<{ found?: boolean; cards?: Array<{ cardType: string; data: Record<string, unknown> }> }>;
391
+ };
392
+ const result = await tool.execute({}, execCtx('10025', ''));
393
+ expect(result.found).toBe(true);
394
+ expect(result.cards).toEqual([expect.objectContaining({ cardType: 'member-login-cta' })]);
395
+ expect(result.cards?.[0]?.data).not.toHaveProperty('loginUrl');
396
+ });
397
+
398
+ it('queryEnrollmentCta 未登录且配了登录入口:出登录卡而非入会卡', async () => {
399
+ const tool = createQueryEnrollmentCtaTool(
400
+ memberPortsOf(async () =>
401
+ ({ ...stubMemberService(''), async getAccount() {
402
+ return { authState: 'unauthenticated' as const, state: 'unknown' as const, observedAt: new Date().toISOString() };
403
+ }, loginUrl: 'alipays://platformapi/startapp?appId=demo&page=login' }) as unknown as Promise<MemberService>),
404
+ ) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
405
+ const result = await tool.execute({}, execCtx());
406
+ // 登录卡而不是入会卡——此刻还没有身份,谈入会为时过早
407
+ expect(result.cards).toEqual([expect.objectContaining({ cardType: 'member-login-cta' })]);
408
+ expect(result).toMatchObject({ loginUrl: 'alipays://platformapi/startapp?appId=demo&page=login' });
409
+ });
410
+
411
+ it('queryLoginCta 主动索要登录入口即出卡', async () => {
412
+ const tool = createQueryLoginCtaTool(
413
+ memberPortsOf(async () =>
414
+ ({ ...stubMemberService('user-token'), loginUrl: 'alipays://platformapi/startapp?appId=demo&page=login' }) as unknown as Promise<MemberService>),
415
+ ) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
416
+ const result = await tool.execute({}, execCtx());
417
+ expect(result.cards).toEqual([expect.objectContaining({ cardType: 'member-login-cta' })]);
418
+ expect(result).toMatchObject({ title: '登录后继续', loginUrl: 'alipays://platformapi/startapp?appId=demo&page=login' });
419
+ });
420
+
421
+ it('queryLoginCta 未配登录入口时仍出卡(深链解耦:无 URL 字段,不设闸于登录态)', async () => {
422
+ const tool = createQueryLoginCtaTool(memberPortsOf(async () => stubMemberService('user-token'))) as unknown as {
423
+ execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>>;
424
+ };
425
+ const result = await tool.execute({}, execCtx());
426
+ expect(result.cards).toEqual([expect.objectContaining({ cardType: 'member-login-cta' })]);
427
+ expect((result.cards as Array<{ data: Record<string, unknown> }>)[0].data).not.toHaveProperty('loginUrl');
428
+ });
429
+
430
+ it('queryEnrollmentCta 只为已登录且确认未入会的用户出卡', async () => {
431
+ const tool = createQueryEnrollmentCtaTool(
432
+ memberPortsOf(async () =>
433
+ ({ ...stubMemberService('user-token'), async getAccount() {
434
+ return { authState: 'authenticated' as const, state: 'not_enrolled' as const, observedAt: new Date().toISOString() };
435
+ } }) as unknown as Promise<MemberService>),
436
+ ) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
437
+ const result = await tool.execute({}, execCtx());
438
+ expect(result).toMatchObject({ authState: 'authenticated', state: 'not_enrolled' });
439
+ expect(result.cards).toEqual([expect.objectContaining({ cardType: 'member-enrollment-cta' })]);
440
+ });
441
+
442
+ it('queryPoints 已登录未入会:出 enrollment-cta 引导卡(半屏入会闭环投影源),话术区分已退会', async () => {
443
+ const notEnrolled = async () =>
444
+ ({ ...stubMemberService('user-token'), async getAccount() {
445
+ return { authState: 'authenticated' as const, state: 'not_enrolled' as const, observedAt: '2026-08-30T00:00:00.000Z' };
446
+ } }) as MemberService;
447
+ const execute = (tool: unknown) =>
448
+ (tool as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> }).execute({}, execCtx());
449
+ const tool = createQueryPointsTool(memberPortsOf(notEnrolled));
450
+ const result = await execute(tool);
451
+ expect(result.noResultsReason).toContain('尚未入会');
452
+ expect(result.cards).toEqual([
453
+ expect.objectContaining({
454
+ cardType: 'member-enrollment-cta',
455
+ data: expect.objectContaining({
456
+ authState: 'authenticated',
457
+ state: 'not_enrolled',
458
+ enrollUrl: 'alipays://platformapi/startapp?appId=demo',
459
+ }),
460
+ }),
461
+ ]);
462
+ // 已退会(closed):同样出引导卡,话术保持「无积分」不称「尚未入会」
463
+ const closed = async () =>
464
+ ({ ...stubMemberService('user-token'), async getAccount() {
465
+ return { authState: 'authenticated' as const, state: 'closed' as const, observedAt: '2026-08-30T00:00:00.000Z' };
466
+ } }) as MemberService;
467
+ const closedResult = await execute(createQueryPointsTool(memberPortsOf(closed)));
468
+ expect(closedResult.noResultsReason).toBe('当前没有可查询的会员积分');
469
+ expect(closedResult.cards).toEqual([expect.objectContaining({ cardType: 'member-enrollment-cta' })]);
470
+ });
471
+
472
+ it('queryPoints 已登录未入会且未配入会入口:恒出卡(无 URL 字段,深链为可选透传素材)', async () => {
473
+ const tool = createQueryPointsTool(
474
+ memberPortsOf(async () =>
475
+ ({ ...stubMemberService('user-token'), enrollUrl: '', async getAccount() {
476
+ return { authState: 'authenticated' as const, state: 'not_enrolled' as const, observedAt: '2026-08-30T00:00:00.000Z' };
477
+ } }) as MemberService),
478
+ ) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
479
+ const result = await tool.execute({}, execCtx());
480
+ expect(result.noResultsReason).toContain('尚未入会');
481
+ expect(result.cards).toEqual([
482
+ expect.objectContaining({
483
+ cardType: 'member-enrollment-cta',
484
+ data: expect.objectContaining({ authState: 'authenticated', state: 'not_enrolled' }),
485
+ }),
486
+ ]);
487
+ expect((result.cards as Array<{ data: Record<string, unknown> }>)[0].data).not.toHaveProperty('enrollUrl');
488
+ });
489
+
490
+ it('queryPoints 未登录不出入会卡(登录 ≠ 入会)', async () => {
491
+ const tool = createQueryPointsTool(
492
+ memberPortsOf(async () => stubMemberService('')),
493
+ ) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
494
+ const result = await tool.execute({}, execCtx());
495
+ expect(result.noResultsReason).toContain('请先登录');
496
+ expect(result).not.toHaveProperty('cards');
497
+ });
498
+
499
+ it('queryMemberInfo 已登录未入会:入会引导卡排首位、资料卡保留', async () => {
500
+ const tool = createQueryMemberInfoTool(
501
+ memberPortsOf(async () =>
502
+ ({ ...stubMemberService('user-token'), async getAccount() {
503
+ return { authState: 'authenticated' as const, state: 'not_enrolled' as const, observedAt: '2026-08-30T00:00:00.000Z' };
504
+ } }) as MemberService),
505
+ ) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
506
+ const result = await tool.execute({}, execCtx());
507
+ expect(result.cards).toEqual([
508
+ expect.objectContaining({ cardType: 'member-enrollment-cta' }),
509
+ expect.objectContaining({ cardType: 'member-info' }),
510
+ ]);
511
+ });
512
+
513
+ it('queryMemberCard 已登录未入会:同构出 enrollment-cta 引导卡', async () => {
514
+ const tool = createQueryMemberCardTool(
515
+ memberPortsOf(async () =>
516
+ ({ ...stubMemberService('user-token'), async getAccount() {
517
+ return { authState: 'authenticated' as const, state: 'not_enrolled' as const, observedAt: '2026-08-30T00:00:00.000Z' };
518
+ } }) as MemberService),
519
+ ) as unknown as { execute(input: unknown, execCtx?: ToolExecContextLike): Promise<Record<string, unknown>> };
520
+ const result = await tool.execute({}, execCtx());
521
+ expect(result.noResultsReason).toContain('尚未入会');
522
+ expect(result.cards).toEqual([
523
+ expect.objectContaining({
524
+ cardType: 'member-enrollment-cta',
525
+ data: expect.objectContaining({ state: 'not_enrolled', enrollUrl: 'alipays://platformapi/startapp?appId=demo' }),
526
+ }),
527
+ ]);
528
+ });
529
+ });
530
+
531
+ describe('createMemberHandler 查询路径门禁(意图与工具路径一致:不再对非 active 态答「积分 0」)', () => {
532
+ const handlerReqCtx = (source: 'dev' | 'anonymous' = 'dev'): RequestContext =>
533
+ ({
534
+ identity: { userId: 'u_1001', source },
535
+ attributes: { mall: { miniAppId: 'demo-app', mallId: 'mall-demo' } },
536
+ }) as RequestContext;
537
+
538
+ /** integrations.resolveService → 预置 account 的 stub service('throw' = 解析失败) */
539
+ const handlerCtx = (accountOrThrow: unknown): ServerContext =>
540
+ ({
541
+ integrations: {
542
+ resolveService: async () => {
543
+ if (accountOrThrow === 'throw') throw new Error('registry down');
544
+ return { ...stubMemberService('user-token'), getAccount: async () => accountOrThrow };
545
+ },
546
+ },
547
+ }) as unknown as ServerContext;
548
+
549
+ it('匿名 → 请先登录后再查询会员信息(若触 provider 必转 unavailable——本例即短路证明)', async () => {
550
+ const ctx = handlerCtx('throw');
551
+ const handler = createMemberHandler(ctx);
552
+ const result = await handler.handle('query-points', {}, ctx, handlerReqCtx('anonymous'));
553
+ expect(result).toEqual({ cards: [], text: '请先登录后再查询会员信息' });
554
+ });
555
+
556
+ it('not_enrolled → 入会引导话术(已登录未入会,非「无积分」)', async () => {
557
+ const ctx = handlerCtx({ authState: 'authenticated' as const, state: 'not_enrolled' as const, observedAt: '2026-08-30T00:00:00.000Z' });
558
+ const handler = createMemberHandler(ctx);
559
+ const result = await handler.handle('query-points', {}, ctx, handlerReqCtx());
560
+ expect(result).toEqual({ cards: [], text: '尚未入会,入会后可查询积分与服务' });
561
+ });
562
+
563
+ it('closed → 积分话术(已退会无积分)', async () => {
564
+ const ctx = handlerCtx({ authState: 'authenticated' as const, state: 'closed' as const, observedAt: '2026-08-30T00:00:00.000Z' });
565
+ const handler = createMemberHandler(ctx);
566
+ const result = await handler.handle('query-points', {}, ctx, handlerReqCtx());
567
+ expect(result).toEqual({ cards: [], text: '当前没有可查询的会员积分' });
568
+ });
569
+
570
+ it('unknown → 无法确认话术', async () => {
571
+ const ctx = handlerCtx({ authState: 'authenticated' as const, state: 'unknown' as const, observedAt: '2026-08-30T00:00:00.000Z' });
572
+ const handler = createMemberHandler(ctx);
573
+ const result = await handler.handle('query-points', {}, ctx, handlerReqCtx());
574
+ expect(result).toEqual({ cards: [], text: '当前无法确认会员状态,请稍后重试' });
575
+ });
576
+
577
+ it('凭证轴未认证 → 请先登录后再查询会员信息(tokenless 合法登录形态)', async () => {
578
+ const ctx = handlerCtx({ authState: 'unauthenticated' as const, state: 'unknown' as const, observedAt: '2026-08-30T00:00:00.000Z' });
579
+ const handler = createMemberHandler(ctx);
580
+ const result = await handler.handle('query-points', {}, ctx, handlerReqCtx());
581
+ expect(result).toEqual({ cards: [], text: '请先登录后再查询会员信息' });
582
+ });
583
+
584
+ it('服务解析失败 → unavailable 软文案(原为裸抛)', async () => {
585
+ const ctx = handlerCtx('throw');
586
+ const handler = createMemberHandler(ctx);
587
+ const result = await handler.handle('query-points', {}, ctx, handlerReqCtx());
588
+ expect(result).toEqual({ cards: [], text: '会员服务暂时不可用,请稍后再试' });
589
+ });
590
+
591
+ it('active → 只出会员卡面,积分流水仅进入文本', async () => {
592
+ const ctx = handlerCtx({ authState: 'authenticated' as const, state: 'active' as const, memberRef: 'member:C-1001', primaryMallCardNo: 'C-1001', observedAt: '2026-08-30T00:00:00.000Z' });
593
+ const handler = createMemberHandler(ctx);
594
+ const result = await handler.handle('query-points', {}, ctx, handlerReqCtx());
595
+ expect(result.cards).toEqual([expect.objectContaining({ cardType: 'member-card-summary' })]);
596
+ expect(result.text).toContain('当前积分 300');
597
+ });
598
+
599
+ it('active + card/benefits 槽解析失败(N1 双槽:service 字段退役,防御分支随架构消解)→ unavailable 收口不裸抛', async () => {
600
+ const warn = vi.fn();
601
+ const ctx = {
602
+ // account 槽短路 status(active),card/benefits 槽放炸药证明失败被 handler 收口
603
+ integrations: { resolveService: async () => { throw new Error('unreachable'); } },
604
+ logger: { warn },
605
+ } as unknown as ServerContext;
606
+ vi.mocked(resolveMemberStatus).mockResolvedValueOnce({
607
+ outcome: 'active',
608
+ account: { authState: 'authenticated', state: 'active', observedAt: '2026-08-30T00:00:00.000Z' },
609
+ });
610
+ const handler = createMemberHandler(ctx);
611
+ const result = await handler.handle('query-points', {}, ctx, handlerReqCtx());
612
+ expect(result).toEqual({ cards: [], text: '会员服务暂时不可用,请稍后再试' });
52
613
  });
53
614
  });