@tbox.cn/app-provider-mock 0.2.0 → 0.3.0
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.
- package/README.md +38 -0
- package/dist/server/index.d.ts +41 -38
- package/dist/server/index.js +258 -130
- package/package.json +6 -5
- package/schemas/mall-info.config.json +11 -0
- package/schemas/member.config.json +13 -0
- package/schemas/parking.config.json +19 -0
- package/schemas/promotion.config.json +13 -0
- package/schemas/shopping.config.json +12 -0
- package/src/server/config.ts +26 -0
- package/src/server/index.ts +16 -4
- package/src/server/mall-info.ts +27 -12
- package/src/server/member.ts +45 -17
- package/src/server/parking.ts +78 -49
- package/src/server/promotion.ts +77 -56
- package/src/server/shopping.ts +41 -3
- package/tbox.module.json +34 -17
- package/tests/config-schema.test.ts +92 -0
- package/tests/integrations-e2e.test.ts +117 -2
- package/tests/mall-info.test.ts +11 -2
- package/tests/mock-impl.test.ts +103 -0
package/src/server/promotion.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mock promotion 实现(provider-mock 自持——R3-1 裁决)。演示面最小集:搜索/精查/推荐/
|
|
3
3
|
* 资产列表/活动列表/领取闭环(acquire 恒 SUCCEEDED)。demo ref 恒 `demo:` 前缀,厂商中立。
|
|
4
|
+
* config 消费:PROMOTION_CFG_DEFAULTS 为契约单源(fail-soft 快照;双锁测试 tests/config-schema.test.ts)。
|
|
4
5
|
*/
|
|
6
|
+
import { resolveConfig, type CfgDefaults } from './config';
|
|
5
7
|
import type {
|
|
6
8
|
PromotionOffer,
|
|
7
9
|
PromotionOfferPage,
|
|
@@ -27,51 +29,16 @@ export type MockPromotionService = PromotionOfferQueryPort &
|
|
|
27
29
|
const now = () => new Date().toISOString();
|
|
28
30
|
const inDays = (d: number) => new Date(Date.now() + d * 86400_000).toISOString();
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
participation: { mode: 'CLAIM_THEN_PURCHASE', steps: [{ title: '领取', description: '点击领取后自动存入卡包' }] },
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
offerRef: 'demo:groupon:2001',
|
|
44
|
-
offerType: 'GROUPON',
|
|
45
|
-
title: '餐饮双人套餐 5 折',
|
|
46
|
-
subtitle: 'mock 演示团购',
|
|
47
|
-
tags: ['餐饮'],
|
|
48
|
-
validity: { start: now(), end: inDays(15) },
|
|
49
|
-
facets: [{ kind: 'GROUPON', requiredParticipants: 2 }],
|
|
50
|
-
availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: now(), authoritative: false },
|
|
51
|
-
participation: { mode: 'CLAIM_THEN_PURCHASE', steps: [{ title: '购买', description: '按团购价下单' }] },
|
|
52
|
-
},
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
const DEMO_ENTITLEMENTS: PromotionEntitlement[] = [
|
|
56
|
-
{
|
|
57
|
-
entitlementRef: 'demo:entitlement:1',
|
|
58
|
-
offerRef: 'demo:coupon:1001',
|
|
59
|
-
title: '满 100 减 20 元券',
|
|
60
|
-
entitlementType: 'COUPON',
|
|
61
|
-
status: 'EFFECTIVE',
|
|
62
|
-
codeMasked: 'DEMO-****-1001',
|
|
63
|
-
},
|
|
64
|
-
];
|
|
65
|
-
|
|
66
|
-
const DEMO_ACTIVITIES: PromotionActivity[] = [
|
|
67
|
-
{
|
|
68
|
-
activityRef: 'demo:activity:3001',
|
|
69
|
-
title: '示例商场周年庆',
|
|
70
|
-
subtitle: 'mock 演示活动',
|
|
71
|
-
registrationRequired: false,
|
|
72
|
-
offerRefs: ['demo:coupon:1001'],
|
|
73
|
-
},
|
|
74
|
-
];
|
|
32
|
+
/** config 契约单源(键集/缺省/整数约束;tests/config-schema.test.ts 双锁) */
|
|
33
|
+
export const PROMOTION_CFG_DEFAULTS = {
|
|
34
|
+
couponTitle: '满 100 减 20 元券',
|
|
35
|
+
couponValueCents: 2000,
|
|
36
|
+
couponThresholdCents: 10000,
|
|
37
|
+
couponValidityDays: 30,
|
|
38
|
+
grouponEnabled: true,
|
|
39
|
+
activityTitle: '示例商场周年庆',
|
|
40
|
+
} as const satisfies CfgDefaults;
|
|
41
|
+
export const PROMOTION_CFG_INT_KEYS = ['couponValueCents', 'couponThresholdCents', 'couponValidityDays'];
|
|
75
42
|
|
|
76
43
|
const emptyPage = (): PromotionOfferPage & PromotionEntitlementPage & PromotionActivityPage => ({
|
|
77
44
|
items: [],
|
|
@@ -79,11 +46,65 @@ const emptyPage = (): PromotionOfferPage & PromotionEntitlementPage & PromotionA
|
|
|
79
46
|
});
|
|
80
47
|
|
|
81
48
|
export class MockPromotionServiceImpl implements MockPromotionService {
|
|
49
|
+
/** 配置快照(fail-soft,构造期一次解析;键集 ≡ PROMOTION_CFG_DEFAULTS) */
|
|
50
|
+
private readonly cfg: typeof PROMOTION_CFG_DEFAULTS;
|
|
51
|
+
/** 供/资产/活动三表(构造期按 cfg 构建——请求级实例,成本可忽略) */
|
|
52
|
+
private readonly offers: PromotionOffer[];
|
|
53
|
+
private readonly entitlements: PromotionEntitlement[];
|
|
54
|
+
private readonly activities: PromotionActivity[];
|
|
55
|
+
|
|
56
|
+
constructor(config: unknown = {}) {
|
|
57
|
+
this.cfg = resolveConfig(config, PROMOTION_CFG_DEFAULTS, PROMOTION_CFG_INT_KEYS);
|
|
58
|
+
const ts = now();
|
|
59
|
+
const couponOffer: PromotionOffer = {
|
|
60
|
+
offerRef: 'demo:coupon:1001',
|
|
61
|
+
offerType: 'COUPON',
|
|
62
|
+
title: this.cfg.couponTitle,
|
|
63
|
+
subtitle: '全场通用(mock 演示)',
|
|
64
|
+
tags: ['通用'],
|
|
65
|
+
validity: { start: ts, end: inDays(this.cfg.couponValidityDays) },
|
|
66
|
+
facets: [{ kind: 'COUPON', discountType: 'AMOUNT', value: this.cfg.couponValueCents, thresholdCents: this.cfg.couponThresholdCents }],
|
|
67
|
+
availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: ts, authoritative: false },
|
|
68
|
+
participation: { mode: 'CLAIM_THEN_PURCHASE', steps: [{ title: '领取', description: '点击领取后自动存入卡包' }] },
|
|
69
|
+
};
|
|
70
|
+
const grouponOffer: PromotionOffer = {
|
|
71
|
+
offerRef: 'demo:groupon:2001',
|
|
72
|
+
offerType: 'GROUPON',
|
|
73
|
+
title: '餐饮双人套餐 5 折',
|
|
74
|
+
subtitle: 'mock 演示团购',
|
|
75
|
+
tags: ['餐饮'],
|
|
76
|
+
validity: { start: ts, end: inDays(15) },
|
|
77
|
+
facets: [{ kind: 'GROUPON', requiredParticipants: 2 }],
|
|
78
|
+
availability: { status: 'AVAILABLE', reasonCodes: [], evaluatedAt: ts, authoritative: false },
|
|
79
|
+
participation: { mode: 'CLAIM_THEN_PURCHASE', steps: [{ title: '购买', description: '按团购价下单' }] },
|
|
80
|
+
};
|
|
81
|
+
this.offers = this.cfg.grouponEnabled ? [couponOffer, grouponOffer] : [couponOffer];
|
|
82
|
+
this.entitlements = [
|
|
83
|
+
{
|
|
84
|
+
entitlementRef: 'demo:entitlement:1',
|
|
85
|
+
offerRef: 'demo:coupon:1001',
|
|
86
|
+
title: this.cfg.couponTitle,
|
|
87
|
+
entitlementType: 'COUPON',
|
|
88
|
+
status: 'EFFECTIVE',
|
|
89
|
+
codeMasked: 'DEMO-****-1001',
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
this.activities = [
|
|
93
|
+
{
|
|
94
|
+
activityRef: 'demo:activity:3001',
|
|
95
|
+
title: this.cfg.activityTitle,
|
|
96
|
+
subtitle: 'mock 演示活动',
|
|
97
|
+
registrationRequired: false,
|
|
98
|
+
offerRefs: ['demo:coupon:1001'],
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
async searchOffers(_q: PromotionQueryContext, input: PromotionSearchInput): Promise<PromotionOfferPage> {
|
|
83
104
|
const keyword = input.keyword?.trim();
|
|
84
105
|
const items = keyword
|
|
85
|
-
?
|
|
86
|
-
:
|
|
106
|
+
? this.offers.filter((o) => o.title.includes(keyword) || o.tags.some((t) => t.includes(keyword)))
|
|
107
|
+
: this.offers;
|
|
87
108
|
return { items, hasMore: false };
|
|
88
109
|
}
|
|
89
110
|
|
|
@@ -91,34 +112,34 @@ export class MockPromotionServiceImpl implements MockPromotionService {
|
|
|
91
112
|
_q: PromotionQueryContext,
|
|
92
113
|
input: { offerRef?: string; entitlementRef?: string; keyword?: string },
|
|
93
114
|
): Promise<PromotionOffer | null> {
|
|
94
|
-
if (input.offerRef) return
|
|
115
|
+
if (input.offerRef) return this.offers.find((o) => o.offerRef === input.offerRef) ?? null;
|
|
95
116
|
if (input.entitlementRef) {
|
|
96
|
-
const ent =
|
|
97
|
-
if (ent?.offerRef) return
|
|
117
|
+
const ent = this.entitlements.find((e) => e.entitlementRef === input.entitlementRef);
|
|
118
|
+
if (ent?.offerRef) return this.offers.find((o) => o.offerRef === ent.offerRef) ?? null;
|
|
98
119
|
return null;
|
|
99
120
|
}
|
|
100
|
-
if (input.keyword) return
|
|
121
|
+
if (input.keyword) return this.offers.find((o) => o.title.includes(input.keyword!)) ?? null;
|
|
101
122
|
return null;
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
async recommendOffers(): Promise<readonly PromotionOffer[]> {
|
|
105
|
-
return
|
|
126
|
+
return this.offers;
|
|
106
127
|
}
|
|
107
128
|
|
|
108
129
|
async listEntitlements(_q: PromotionQueryContext, _input: PromotionSearchInput): Promise<PromotionEntitlementPage> {
|
|
109
|
-
return { items:
|
|
130
|
+
return { items: this.entitlements, hasMore: false };
|
|
110
131
|
}
|
|
111
132
|
|
|
112
133
|
async getEntitlementDetail(_q: PromotionQueryContext, entitlementRef: string): Promise<PromotionEntitlement | null> {
|
|
113
|
-
return
|
|
134
|
+
return this.entitlements.find((e) => e.entitlementRef === entitlementRef) ?? null;
|
|
114
135
|
}
|
|
115
136
|
|
|
116
137
|
async listActivities(): Promise<PromotionActivityPage> {
|
|
117
|
-
return { items:
|
|
138
|
+
return { items: this.activities, hasMore: false };
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
async getActivityDetail(_q: PromotionQueryContext, activityRef: string): Promise<PromotionActivityDetail | null> {
|
|
121
|
-
const activity =
|
|
142
|
+
const activity = this.activities.find((a) => a.activityRef === activityRef);
|
|
122
143
|
if (!activity) return null;
|
|
123
144
|
return { ...activity, sessions: [], description: 'mock 演示活动详情' };
|
|
124
145
|
}
|
package/src/server/shopping.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - merchantId 连字符格式(demo-shop-NNNN):LLM 从卡片 value 提取友好;getShop 入参 trim。
|
|
11
11
|
* - catalog 空实现(对齐真实档 C 端):演示面不覆盖商品目录,门店链路为演示重点。
|
|
12
12
|
* - 构造器接受 seed(默认 DEMO_BUILDINGS):测试可注入定制数据。
|
|
13
|
+
* - config 消费:SHOPPING_CFG_DEFAULTS 为契约单源(fail-soft 快照;extraShop* 追加演示门店)。
|
|
13
14
|
*/
|
|
15
|
+
import { resolveConfig, type CfgDefaults } from './config';
|
|
14
16
|
import type {
|
|
15
17
|
CatalogQueryPort,
|
|
16
18
|
CategoryRecord,
|
|
@@ -48,6 +50,18 @@ interface DemoBuilding {
|
|
|
48
50
|
floors: DemoFloor[];
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/** config 契约单源(键集/缺省/整数约束;tests/config-schema.test.ts 双锁) */
|
|
54
|
+
export const SHOPPING_CFG_DEFAULTS = {
|
|
55
|
+
extraShopName: '', // '' = 不追加门店(空串 = 缺省/不生效语义)
|
|
56
|
+
extraShopCategory: '餐饮',
|
|
57
|
+
extraShopFloor: '3 楼',
|
|
58
|
+
extraShopUnit: '304',
|
|
59
|
+
extraShopOpen: true,
|
|
60
|
+
} as const satisfies CfgDefaults;
|
|
61
|
+
export const SHOPPING_CFG_INT_KEYS: readonly string[] = [];
|
|
62
|
+
/** 楼层白名单(消费点守卫 + schema enum 同值——防手编楼层不在目录内) */
|
|
63
|
+
export const SHOPPING_FLOORS = ['B1', '1 楼', '2 楼', '3 楼', '4 楼'] as const;
|
|
64
|
+
|
|
51
65
|
/** seed 单一真源:1 楼栋 A 栋 / 6 类目 / 12 店(西贝休息中演示营业状态多样性;NIKE 双店支撑快捷命令) */
|
|
52
66
|
const DEMO_BUILDINGS: DemoBuilding[] = [
|
|
53
67
|
{
|
|
@@ -137,16 +151,40 @@ function toFloorRecord(floor: DemoFloor): FloorRecord {
|
|
|
137
151
|
|
|
138
152
|
export class MockShoppingServiceImpl implements
|
|
139
153
|
CatalogQueryPort, ShopDirectoryQueryPort, CommercialCategoryQueryPort, MallDirectoryQueryPort {
|
|
154
|
+
/** 配置快照(fail-soft,构造期一次解析;键集 ≡ SHOPPING_CFG_DEFAULTS) */
|
|
155
|
+
private readonly cfg: typeof SHOPPING_CFG_DEFAULTS;
|
|
140
156
|
private readonly buildings: readonly DemoBuilding[];
|
|
141
157
|
private readonly shops: ShopRecord[];
|
|
142
158
|
private readonly categories: CategoryRecord[];
|
|
143
159
|
|
|
144
|
-
constructor(seed: readonly DemoBuilding[] = DEMO_BUILDINGS) {
|
|
145
|
-
this.
|
|
146
|
-
|
|
160
|
+
constructor(seed: readonly DemoBuilding[] = DEMO_BUILDINGS, config: unknown = {}) {
|
|
161
|
+
this.cfg = resolveConfig(config, SHOPPING_CFG_DEFAULTS, SHOPPING_CFG_INT_KEYS);
|
|
162
|
+
const withExtra = this.withExtraShop(seed);
|
|
163
|
+
this.buildings = withExtra;
|
|
164
|
+
this.shops = flattenShops(withExtra);
|
|
147
165
|
this.categories = aggregateCategories(this.shops);
|
|
148
166
|
}
|
|
149
167
|
|
|
168
|
+
/** extraShopName 非空 → 指定楼层追加演示门店(名称 trim 判空 + 楼层白名单回退——消费点守卫) */
|
|
169
|
+
private withExtraShop(seed: readonly DemoBuilding[]): readonly DemoBuilding[] {
|
|
170
|
+
const name = this.cfg.extraShopName.trim();
|
|
171
|
+
if (!name) return seed;
|
|
172
|
+
const floor = (SHOPPING_FLOORS as readonly string[]).includes(this.cfg.extraShopFloor)
|
|
173
|
+
? this.cfg.extraShopFloor
|
|
174
|
+
: '3 楼';
|
|
175
|
+
const extra: ShopSeed = {
|
|
176
|
+
merchantId: 'demo-shop-1013',
|
|
177
|
+
name,
|
|
178
|
+
businessCategories: [this.cfg.extraShopCategory],
|
|
179
|
+
unitName: this.cfg.extraShopUnit,
|
|
180
|
+
status: this.cfg.extraShopOpen ? { code: 'enabled', label: '营业中' } : { code: 'disabled', label: '休息中' },
|
|
181
|
+
};
|
|
182
|
+
return seed.map((b) => ({
|
|
183
|
+
...b,
|
|
184
|
+
floors: b.floors.map((f) => (f.floorName === floor ? { ...f, shops: [...f.shops, extra] } : f)),
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
150
188
|
// ===== ShopDirectoryQueryPort =====
|
|
151
189
|
|
|
152
190
|
async searchShops(criteria: ShopSearchCriteria): Promise<ShoppingPage<ShopRecord>> {
|
package/tbox.module.json
CHANGED
|
@@ -24,119 +24,136 @@
|
|
|
24
24
|
"credentialType": "-",
|
|
25
25
|
"local": true,
|
|
26
26
|
"provider": "mock",
|
|
27
|
-
"implementation": "mock-member@1"
|
|
27
|
+
"implementation": "mock-member@1",
|
|
28
|
+
"configSchema": "schemas/member.config.json"
|
|
28
29
|
},
|
|
29
30
|
{
|
|
30
31
|
"service": "member.profile",
|
|
31
32
|
"credentialType": "-",
|
|
32
33
|
"local": true,
|
|
33
34
|
"provider": "mock",
|
|
34
|
-
"implementation": "mock-member@1"
|
|
35
|
+
"implementation": "mock-member@1",
|
|
36
|
+
"configSchema": "schemas/member.config.json"
|
|
35
37
|
},
|
|
36
38
|
{
|
|
37
39
|
"service": "member.card",
|
|
38
40
|
"credentialType": "-",
|
|
39
41
|
"local": true,
|
|
40
42
|
"provider": "mock",
|
|
41
|
-
"implementation": "mock-member@1"
|
|
43
|
+
"implementation": "mock-member@1",
|
|
44
|
+
"configSchema": "schemas/member.config.json"
|
|
42
45
|
},
|
|
43
46
|
{
|
|
44
47
|
"service": "member.benefits",
|
|
45
48
|
"credentialType": "-",
|
|
46
49
|
"local": true,
|
|
47
50
|
"provider": "mock",
|
|
48
|
-
"implementation": "mock-member@1"
|
|
51
|
+
"implementation": "mock-member@1",
|
|
52
|
+
"configSchema": "schemas/member.config.json"
|
|
49
53
|
},
|
|
50
54
|
{
|
|
51
55
|
"service": "member.enrollment",
|
|
52
56
|
"credentialType": "-",
|
|
53
57
|
"local": true,
|
|
54
58
|
"provider": "mock",
|
|
55
|
-
"implementation": "mock-member@1"
|
|
59
|
+
"implementation": "mock-member@1",
|
|
60
|
+
"configSchema": "schemas/member.config.json"
|
|
56
61
|
},
|
|
57
62
|
{
|
|
58
63
|
"service": "parking.query",
|
|
59
64
|
"credentialType": "-",
|
|
60
65
|
"local": true,
|
|
61
66
|
"provider": "mock",
|
|
62
|
-
"implementation": "mock-parking@1"
|
|
67
|
+
"implementation": "mock-parking@1",
|
|
68
|
+
"configSchema": "schemas/parking.config.json"
|
|
63
69
|
},
|
|
64
70
|
{
|
|
65
71
|
"service": "parking.payment",
|
|
66
72
|
"credentialType": "-",
|
|
67
73
|
"local": true,
|
|
68
74
|
"provider": "mock",
|
|
69
|
-
"implementation": "mock-parking@1"
|
|
75
|
+
"implementation": "mock-parking@1",
|
|
76
|
+
"configSchema": "schemas/parking.config.json"
|
|
70
77
|
},
|
|
71
78
|
{
|
|
72
79
|
"service": "parking.vehicle",
|
|
73
80
|
"credentialType": "-",
|
|
74
81
|
"local": true,
|
|
75
82
|
"provider": "mock",
|
|
76
|
-
"implementation": "mock-parking@1"
|
|
83
|
+
"implementation": "mock-parking@1",
|
|
84
|
+
"configSchema": "schemas/parking.config.json"
|
|
77
85
|
},
|
|
78
86
|
{
|
|
79
87
|
"service": "promotion.offers",
|
|
80
88
|
"credentialType": "-",
|
|
81
89
|
"local": true,
|
|
82
90
|
"provider": "mock",
|
|
83
|
-
"implementation": "mock-promotion@1"
|
|
91
|
+
"implementation": "mock-promotion@1",
|
|
92
|
+
"configSchema": "schemas/promotion.config.json"
|
|
84
93
|
},
|
|
85
94
|
{
|
|
86
95
|
"service": "promotion.entitlements",
|
|
87
96
|
"credentialType": "-",
|
|
88
97
|
"local": true,
|
|
89
98
|
"provider": "mock",
|
|
90
|
-
"implementation": "mock-promotion@1"
|
|
99
|
+
"implementation": "mock-promotion@1",
|
|
100
|
+
"configSchema": "schemas/promotion.config.json"
|
|
91
101
|
},
|
|
92
102
|
{
|
|
93
103
|
"service": "promotion.activities",
|
|
94
104
|
"credentialType": "-",
|
|
95
105
|
"local": true,
|
|
96
106
|
"provider": "mock",
|
|
97
|
-
"implementation": "mock-promotion@1"
|
|
107
|
+
"implementation": "mock-promotion@1",
|
|
108
|
+
"configSchema": "schemas/promotion.config.json"
|
|
98
109
|
},
|
|
99
110
|
{
|
|
100
111
|
"service": "promotion.acquisition",
|
|
101
112
|
"credentialType": "-",
|
|
102
113
|
"local": true,
|
|
103
114
|
"provider": "mock",
|
|
104
|
-
"implementation": "mock-promotion@1"
|
|
115
|
+
"implementation": "mock-promotion@1",
|
|
116
|
+
"configSchema": "schemas/promotion.config.json"
|
|
105
117
|
},
|
|
106
118
|
{
|
|
107
119
|
"service": "shopping-guide.catalog",
|
|
108
120
|
"credentialType": "-",
|
|
109
121
|
"local": true,
|
|
110
122
|
"provider": "mock",
|
|
111
|
-
"implementation": "mock-shopping@1"
|
|
123
|
+
"implementation": "mock-shopping@1",
|
|
124
|
+
"configSchema": "schemas/shopping.config.json"
|
|
112
125
|
},
|
|
113
126
|
{
|
|
114
127
|
"service": "shopping-guide.shops",
|
|
115
128
|
"credentialType": "-",
|
|
116
129
|
"local": true,
|
|
117
130
|
"provider": "mock",
|
|
118
|
-
"implementation": "mock-shopping@1"
|
|
131
|
+
"implementation": "mock-shopping@1",
|
|
132
|
+
"configSchema": "schemas/shopping.config.json"
|
|
119
133
|
},
|
|
120
134
|
{
|
|
121
135
|
"service": "shopping-guide.categories",
|
|
122
136
|
"credentialType": "-",
|
|
123
137
|
"local": true,
|
|
124
138
|
"provider": "mock",
|
|
125
|
-
"implementation": "mock-shopping@1"
|
|
139
|
+
"implementation": "mock-shopping@1",
|
|
140
|
+
"configSchema": "schemas/shopping.config.json"
|
|
126
141
|
},
|
|
127
142
|
{
|
|
128
143
|
"service": "shopping-guide.directory",
|
|
129
144
|
"credentialType": "-",
|
|
130
145
|
"local": true,
|
|
131
146
|
"provider": "mock",
|
|
132
|
-
"implementation": "mock-shopping@1"
|
|
147
|
+
"implementation": "mock-shopping@1",
|
|
148
|
+
"configSchema": "schemas/shopping.config.json"
|
|
133
149
|
},
|
|
134
150
|
{
|
|
135
151
|
"service": "mall.info",
|
|
136
152
|
"credentialType": "-",
|
|
137
153
|
"local": true,
|
|
138
154
|
"provider": "mock",
|
|
139
|
-
"implementation": "mock-mall-info@1"
|
|
155
|
+
"implementation": "mock-mall-info@1",
|
|
156
|
+
"configSchema": "schemas/mall-info.config.json"
|
|
140
157
|
}
|
|
141
158
|
]
|
|
142
159
|
},
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config schema 双锁(供给面 ↔ 消费面一致性):
|
|
3
|
+
* ① 键集双向锁(schema.properties ≡ DEFAULTS——死字段/漏字段一次全拦)
|
|
4
|
+
* ② default 漂移锁(schema 表单缺省 ≡ 实现缺省——面板不改动时的运行时真值)
|
|
5
|
+
* ③ 整数对偶锁(schema type:'integer' ⟺ INT_KEYS——消费点整数守卫与表单引导同源)
|
|
6
|
+
* ④ manifest 分发面锁(17 槽 configSchema 引用齐全 + 文件随包存在 + 槽→域文件映射)
|
|
7
|
+
* ⑤ 楼层白名单同值锁(shopping enum ≡ SHOPPING_FLOORS——消费点守卫与引导同源)
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { MEMBER_CFG_DEFAULTS, MEMBER_CFG_INT_KEYS } from '../src/server/member';
|
|
14
|
+
import { PARKING_CFG_DEFAULTS, PARKING_CFG_INT_KEYS } from '../src/server/parking';
|
|
15
|
+
import { PROMOTION_CFG_DEFAULTS, PROMOTION_CFG_INT_KEYS } from '../src/server/promotion';
|
|
16
|
+
import { SHOPPING_CFG_DEFAULTS, SHOPPING_CFG_INT_KEYS, SHOPPING_FLOORS } from '../src/server/shopping';
|
|
17
|
+
import { MALL_INFO_CFG_DEFAULTS, MALL_INFO_CFG_INT_KEYS } from '../src/server/mall-info';
|
|
18
|
+
|
|
19
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
interface SchemaProperty { type?: string; default?: unknown; enum?: readonly string[] }
|
|
22
|
+
interface SchemaFile { properties: Record<string, SchemaProperty> }
|
|
23
|
+
|
|
24
|
+
interface DomainCase {
|
|
25
|
+
name: string;
|
|
26
|
+
file: string;
|
|
27
|
+
defaults: Readonly<Record<string, string | number | boolean>>;
|
|
28
|
+
intKeys: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DOMAINS: readonly DomainCase[] = [
|
|
32
|
+
{ name: 'member', file: 'member.config.json', defaults: MEMBER_CFG_DEFAULTS, intKeys: MEMBER_CFG_INT_KEYS },
|
|
33
|
+
{ name: 'parking', file: 'parking.config.json', defaults: PARKING_CFG_DEFAULTS, intKeys: PARKING_CFG_INT_KEYS },
|
|
34
|
+
{ name: 'promotion', file: 'promotion.config.json', defaults: PROMOTION_CFG_DEFAULTS, intKeys: PROMOTION_CFG_INT_KEYS },
|
|
35
|
+
{ name: 'shopping', file: 'shopping.config.json', defaults: SHOPPING_CFG_DEFAULTS, intKeys: SHOPPING_CFG_INT_KEYS },
|
|
36
|
+
{ name: 'mall-info', file: 'mall-info.config.json', defaults: MALL_INFO_CFG_DEFAULTS, intKeys: MALL_INFO_CFG_INT_KEYS },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const readSchema = (file: string): SchemaFile =>
|
|
40
|
+
JSON.parse(readFileSync(path.join(pkgRoot, 'schemas', file), 'utf8')) as SchemaFile;
|
|
41
|
+
|
|
42
|
+
describe('provider-mock:config schema 双锁(供给面 ≡ 消费面)', () => {
|
|
43
|
+
it('① 键集双向锁:schema.properties ≡ DEFAULTS 键集', () => {
|
|
44
|
+
for (const d of DOMAINS) {
|
|
45
|
+
const schema = readSchema(d.file);
|
|
46
|
+
expect(Object.keys(schema.properties).sort(), `${d.name} 键集`).toEqual(Object.keys(d.defaults).sort());
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('② default 漂移锁:schema 缺省 ≡ 实现缺省', () => {
|
|
51
|
+
for (const d of DOMAINS) {
|
|
52
|
+
const schema = readSchema(d.file);
|
|
53
|
+
for (const [key, value] of Object.entries(d.defaults)) {
|
|
54
|
+
expect(schema.properties[key]?.default, `${d.name}.${key} default 漂移`).toEqual(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('③ 整数对偶锁:schema type=integer ⟺ INT_KEYS(分式字段不得入 intKeys)', () => {
|
|
60
|
+
for (const d of DOMAINS) {
|
|
61
|
+
const schema = readSchema(d.file);
|
|
62
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
63
|
+
expect(prop.type === 'integer', `${d.name}.${key} 整数对偶`).toBe(d.intKeys.includes(key));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('④ manifest 分发面锁:17 槽引用齐全 + 槽→域映射 + 文件随包', () => {
|
|
69
|
+
const manifest = JSON.parse(readFileSync(path.join(pkgRoot, 'tbox.module.json'), 'utf8')) as {
|
|
70
|
+
contributes: { providers: { slots: Array<{ service: string; configSchema?: string }> } };
|
|
71
|
+
};
|
|
72
|
+
const slots = manifest.contributes.providers.slots;
|
|
73
|
+
expect(slots).toHaveLength(17);
|
|
74
|
+
const fileOfPrefix: Record<string, string> = {
|
|
75
|
+
member: 'member.config.json',
|
|
76
|
+
parking: 'parking.config.json',
|
|
77
|
+
promotion: 'promotion.config.json',
|
|
78
|
+
'shopping-guide': 'shopping.config.json',
|
|
79
|
+
mall: 'mall-info.config.json',
|
|
80
|
+
};
|
|
81
|
+
for (const slot of slots) {
|
|
82
|
+
const prefix = slot.service.slice(0, slot.service.indexOf('.'));
|
|
83
|
+
expect(slot.configSchema, `${slot.service} configSchema 缺失`).toBe(`schemas/${fileOfPrefix[prefix]}`);
|
|
84
|
+
expect(existsSync(path.join(pkgRoot, slot.configSchema!)), `${slot.service} schema 文件不存在`).toBe(true);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('⑤ 楼层 enum ≡ SHOPPING_FLOORS(消费点守卫与表单引导同源)', () => {
|
|
89
|
+
const schema = readSchema('shopping.config.json');
|
|
90
|
+
expect(schema.properties.extraShopFloor?.enum).toEqual([...SHOPPING_FLOORS]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
8
8
|
import { createIntegrationsRegistry, createAuthStrategyRegistry, createEnvironmentSecretProvider, createServerContext } from '@tbox.cn/app-sdk/server';
|
|
9
9
|
import { serverModule, MOCK_SHELL_USER_ID, enrollMockMember, logoutMockMember, __resetMockMemberState } from '../src/server/index';
|
|
10
|
+
import { mallControlPlaneOf } from '@tbox.cn/app-contracts-mall/server';
|
|
10
11
|
import type { IntegrationsConfig } from '@tbox.cn/app-contracts';
|
|
11
12
|
import type { RequestContext } from '@tbox.cn/app-contracts';
|
|
12
13
|
|
|
@@ -54,9 +55,9 @@ function requestContext(mallId = 'mall-demo'): RequestContext {
|
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function fixtureCtx() {
|
|
58
|
+
function fixtureCtx(integrationsOverride?: IntegrationsConfig) {
|
|
58
59
|
const registry = createIntegrationsRegistry({
|
|
59
|
-
integrations,
|
|
60
|
+
integrations: integrationsOverride ?? integrations,
|
|
60
61
|
catalog,
|
|
61
62
|
authStrategies: createAuthStrategyRegistry(),
|
|
62
63
|
secretProvider: createEnvironmentSecretProvider({ source: {}, production: false }),
|
|
@@ -217,3 +218,117 @@ describe('C5:scoped mock 换码器(入会闭环身份供给)', () => {
|
|
|
217
218
|
expect(result.credentials?.token).toBe('demo:token:demo-user');
|
|
218
219
|
});
|
|
219
220
|
});
|
|
221
|
+
|
|
222
|
+
describe('F2 端到端:config 级联(resolveService 全链——五层浅合并消费)', () => {
|
|
223
|
+
it('槽级 config 流入:parking.query.config.feeCents=200 → 报价同源', async () => {
|
|
224
|
+
const override: IntegrationsConfig = {
|
|
225
|
+
...integrations,
|
|
226
|
+
services: {
|
|
227
|
+
...integrations.services,
|
|
228
|
+
'parking.query': { ...integrations.services['parking.query'], config: { feeCents: 200 } },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const { ctx } = fixtureCtx(override);
|
|
232
|
+
const parking = (await ctx.integrations!.resolveService({
|
|
233
|
+
service: 'parking.query',
|
|
234
|
+
scope: { mallId: 'mall-demo' },
|
|
235
|
+
request: requestContext(),
|
|
236
|
+
})) as { queryFee(q: unknown, input: { plateNo: string }): Promise<Array<{ totalCents: number; payableCents: number }>> };
|
|
237
|
+
const quotes = await parking.queryFee({ scope: {} } as never, { plateNo: 'demo-plate' });
|
|
238
|
+
expect(quotes[0]?.totalCents).toBe(200);
|
|
239
|
+
expect(quotes[0]?.payableCents).toBe(200);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('root config 级联:根 config.cardName → member 槽实例同源', async () => {
|
|
243
|
+
const override: IntegrationsConfig = { ...integrations, config: { cardName: 'Root 卡' } };
|
|
244
|
+
const { ctx } = fixtureCtx(override);
|
|
245
|
+
const member = (await ctx.integrations!.resolveService({
|
|
246
|
+
service: 'member.account',
|
|
247
|
+
scope: { mallId: 'mall-demo' },
|
|
248
|
+
request: requestContext(),
|
|
249
|
+
})) as { queryPoints(u: string): Promise<{ membershipLabel: string }> };
|
|
250
|
+
await expect(member.queryPoints('u1')).resolves.toMatchObject({ membershipLabel: 'Root 卡' });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('浅合并键级覆盖:root 共享键 + slot 差异键并行生效(面板分层配置语义)', async () => {
|
|
254
|
+
const override: IntegrationsConfig = {
|
|
255
|
+
...integrations,
|
|
256
|
+
config: { parkName: 'RootP' },
|
|
257
|
+
services: {
|
|
258
|
+
...integrations.services,
|
|
259
|
+
'parking.query': { ...integrations.services['parking.query'], config: { feeCents: 200 } },
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const { ctx } = fixtureCtx(override);
|
|
263
|
+
const parking = (await ctx.integrations!.resolveService({
|
|
264
|
+
service: 'parking.query',
|
|
265
|
+
scope: { mallId: 'mall-demo' },
|
|
266
|
+
request: requestContext(),
|
|
267
|
+
})) as {
|
|
268
|
+
queryFee(q: unknown, input: { plateNo: string }): Promise<Array<{ totalCents: number }>>;
|
|
269
|
+
queryParkingInfo(q: unknown, kind: string): Promise<{ title: string }>;
|
|
270
|
+
};
|
|
271
|
+
const quotes = await parking.queryFee({ scope: {} } as never, { plateNo: 'demo-plate' });
|
|
272
|
+
expect(quotes[0]?.totalCents).toBe(200); // slot 差异键
|
|
273
|
+
const info = await parking.queryParkingInfo({ scope: {} } as never, 'chargeRules');
|
|
274
|
+
expect(info.title).toBe('RootP'); // root 共享键
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('domain 层级联 + slot 胜 domain:域级键流入 + 槽级同名键覆盖(键级层序)', async () => {
|
|
278
|
+
const override: IntegrationsConfig = {
|
|
279
|
+
...integrations,
|
|
280
|
+
domains: { parking: { config: { parkName: 'DomainP', remainingSpaces: 99 } } },
|
|
281
|
+
services: {
|
|
282
|
+
...integrations.services,
|
|
283
|
+
'parking.query': { ...integrations.services['parking.query'], config: { feeCents: 250, parkName: 'SlotP' } },
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
const { ctx } = fixtureCtx(override);
|
|
287
|
+
const parking = (await ctx.integrations!.resolveService({
|
|
288
|
+
service: 'parking.query',
|
|
289
|
+
scope: { mallId: 'mall-demo' },
|
|
290
|
+
request: requestContext(),
|
|
291
|
+
})) as {
|
|
292
|
+
queryFee(q: unknown, input: { plateNo: string }): Promise<Array<{ totalCents: number }>>;
|
|
293
|
+
queryParkingInfo(q: unknown, kind: string): Promise<{ title: string; entries: Array<{ label: string; value: string }> }>;
|
|
294
|
+
};
|
|
295
|
+
const quotes = await parking.queryFee({ scope: {} } as never, { plateNo: 'demo-plate' });
|
|
296
|
+
expect(quotes[0]?.totalCents).toBe(250); // 槽级差异键
|
|
297
|
+
const info = await parking.queryParkingInfo({ scope: {} } as never, 'chargeRules');
|
|
298
|
+
expect(info.title).toBe('SlotP'); // slot 胜 domain(同名键)
|
|
299
|
+
expect(info.entries).toContainEqual({ label: '剩余车位', value: '99' }); // 域级键流入(槽未覆盖)
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── 隐式默认单实例声明(零拓扑兜底——provider-mock declareProviderInstances) ──
|
|
304
|
+
|
|
305
|
+
describe('隐式默认单实例声明(零拓扑兜底)', () => {
|
|
306
|
+
it('register → 声明快照三键断言(id/name/attributes.miniAppId)', () => {
|
|
307
|
+
const { registry } = fixtureCtx();
|
|
308
|
+
expect(registry.providerInstanceDeclarations()['mock']).toEqual([
|
|
309
|
+
{ id: 'mall-demo', name: '星河 Mock 小镇', attributes: { miniAppId: 'mock-demo-app' } },
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('零拓扑 config + 声明 → mallControlPlaneOf 融合产出 mall-demo', () => {
|
|
314
|
+
const { registry } = fixtureCtx();
|
|
315
|
+
const cp = mallControlPlaneOf(
|
|
316
|
+
{ provider: 'mock', services: {} },
|
|
317
|
+
registry.providerInstanceDeclarations(),
|
|
318
|
+
);
|
|
319
|
+
expect(cp.malls).toEqual([
|
|
320
|
+
{ mallId: 'mall-demo', name: '星河 Mock 小镇', miniAppId: 'mock-demo-app' },
|
|
321
|
+
]);
|
|
322
|
+
expect(cp.defaultMallId).toBe('mall-demo');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('异构形态(无 root.provider 逐槽绑定)→ 不融合(边界锁定)', () => {
|
|
326
|
+
const { registry } = fixtureCtx();
|
|
327
|
+
const cp = mallControlPlaneOf(
|
|
328
|
+
{ services: { 'parking.query': { provider: 'mock', implementation: 'mock-parking@1' } } },
|
|
329
|
+
registry.providerInstanceDeclarations(),
|
|
330
|
+
);
|
|
331
|
+
expect(cp.malls).toEqual([]);
|
|
332
|
+
expect(cp.defaultMallId).toBeUndefined();
|
|
333
|
+
});
|
|
334
|
+
});
|
package/tests/mall-info.test.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
7
|
import { mockMallInfoFactory, MOCK_MALL_INFO_PROVIDER, MOCK_MALL_INFO_IMPL } from '../src/server/mall-info';
|
|
8
8
|
|
|
9
|
-
function makePort() {
|
|
9
|
+
function makePort(config: unknown = {}) {
|
|
10
10
|
return mockMallInfoFactory({
|
|
11
11
|
provider: MOCK_MALL_INFO_PROVIDER,
|
|
12
12
|
implementation: MOCK_MALL_INFO_IMPL,
|
|
13
13
|
instanceId: '',
|
|
14
|
-
config
|
|
14
|
+
config,
|
|
15
15
|
logger: { info: () => undefined, warn: () => undefined, error: () => undefined },
|
|
16
16
|
service: 'mall.info',
|
|
17
17
|
} as never) as { getMallInfo(mallId: string): Promise<{ mallId: string; name: string } | undefined> };
|
|
@@ -44,4 +44,13 @@ describe('mockMallInfoFactory:请求形状守卫', () => {
|
|
|
44
44
|
expect(MOCK_MALL_INFO_PROVIDER).toBe('mock');
|
|
45
45
|
expect(MOCK_MALL_INFO_IMPL).toBe('mock-mall-info@1');
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
it('config 驱动:name 直出 / 空串回退动态别名 / 分式经纬度合法', async () => {
|
|
49
|
+
const named = makePort({ name: '星河 Mock 小镇' });
|
|
50
|
+
await expect(named.getMallInfo('1102588')).resolves.toMatchObject({ name: '星河 Mock 小镇' });
|
|
51
|
+
const fallback = makePort({ name: '', latitude: 31.2304 });
|
|
52
|
+
const info = await fallback.getMallInfo('1102588');
|
|
53
|
+
expect(info?.name).toBe('示例商场(1102588)');
|
|
54
|
+
expect((info as { latitude?: number })?.latitude).toBe(31.2304);
|
|
55
|
+
});
|
|
47
56
|
});
|