@ploykit/ui 0.7.4 → 0.8.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.
@@ -34,6 +34,7 @@ vi.mock('@ploykit/client', () => ({
34
34
  vi.mock('../../../components/toast', () => ({ toast: toastMocks }));
35
35
 
36
36
  const ENVELOPE = {
37
+ mail_mode: 'smtp',
37
38
  items: { banner_text: '今晚例行维护', banner_kind: 'warn' },
38
39
  effective: {
39
40
  banner_text: '今晚例行维护',
@@ -74,13 +75,20 @@ beforeEach(() => {
74
75
  });
75
76
 
76
77
  describe('AdminSettingsPage', () => {
77
- it('渲染四卡并按 effective 回填表单(公告预览同步)', async () => {
78
+ it('渲染各卡并按 effective 回填表单(公告预览同步)', async () => {
78
79
  renderPage();
79
80
 
80
81
  expect(await screen.findByText('顶部公告横条')).toBeInTheDocument();
81
82
  expect(screen.getByText('全站"维护中"提示条')).toBeInTheDocument();
82
83
  expect(screen.getByText('注册开关与域名白名单')).toBeInTheDocument();
84
+ expect(
85
+ screen.getByText('每用户 / 每 IP 限流与豁免名单'),
86
+ ).toBeInTheDocument();
87
+ expect(screen.getByText('SMTP 发信配置')).toBeInTheDocument();
88
+ expect(screen.getByText('搜索引擎站点验证')).toBeInTheDocument();
83
89
  expect(screen.getByText('发送测试邮件')).toBeInTheDocument();
90
+ // 基座信封无 oauth 字段 → 登录方式卡不渲染
91
+ expect(screen.queryByText('OAuth 第三方登录')).not.toBeInTheDocument();
84
92
 
85
93
  expect(screen.getByLabelText('公告文本')).toHaveValue('今晚例行维护');
86
94
  expect(screen.getByRole('status')).toHaveTextContent('今晚例行维护');
@@ -257,4 +265,215 @@ describe('AdminSettingsPage', () => {
257
265
  expect(msg).toContain('成功 2/3');
258
266
  expect(msg).toContain('每 IP 限流');
259
267
  });
268
+
269
+ describe('SMTP 卡(UP-1)', () => {
270
+ const SMTP_EFFECTIVE = {
271
+ smtp_host: 'smtp.example.com',
272
+ smtp_port: '465',
273
+ smtp_user: 'api',
274
+ smtp_pass: '******',
275
+ smtp_from: 'no-reply@example.com',
276
+ };
277
+ function renderWithSmtp() {
278
+ mockedGet.mockResolvedValueOnce({
279
+ ...ENVELOPE,
280
+ effective: { ...ENVELOPE.effective, ...SMTP_EFFECTIVE },
281
+ });
282
+ return renderPage();
283
+ }
284
+
285
+ it('掩码回显不回填:smtp_pass 回显 "******",密码框保持空且 placeholder 为掩码', async () => {
286
+ renderWithSmtp();
287
+ expect(await screen.findByText('SMTP 发信配置')).toBeInTheDocument();
288
+ expect(screen.getByLabelText('SMTP 服务器')).toHaveValue(
289
+ 'smtp.example.com',
290
+ );
291
+ expect(screen.getByLabelText('端口(留空 = 缺省 587)')).toHaveValue(
292
+ '465',
293
+ );
294
+ expect(screen.getByLabelText('SMTP 用户名')).toHaveValue('api');
295
+ expect(screen.getByLabelText('发件人地址')).toHaveValue(
296
+ 'no-reply@example.com',
297
+ );
298
+ const pass = screen.getByLabelText('SMTP 密码(留空 = 不修改)');
299
+ expect(pass).toHaveValue('');
300
+ expect(pass).toHaveAttribute('placeholder', '******');
301
+ expect(pass).toHaveAttribute('type', 'password');
302
+ // 卡头 Badge 显示 mail_mode
303
+ expect(screen.getByText('SMTP 直发')).toBeInTheDocument();
304
+ });
305
+
306
+ it('保存 → PUT 5 键(密码原文只在用户主动输入时提交)', async () => {
307
+ renderWithSmtp();
308
+ await screen.findByText('SMTP 发信配置');
309
+
310
+ fireEvent.change(screen.getByLabelText('SMTP 服务器'), {
311
+ target: { value: 'smtp2.example.com' },
312
+ });
313
+ fireEvent.change(screen.getByLabelText('端口(留空 = 缺省 587)'), {
314
+ target: { value: '587' },
315
+ });
316
+ fireEvent.change(screen.getByLabelText('SMTP 用户名'), {
317
+ target: { value: 'api2' },
318
+ });
319
+ fireEvent.change(screen.getByLabelText('SMTP 密码(留空 = 不修改)'), {
320
+ target: { value: 's3cret' },
321
+ });
322
+ fireEvent.change(screen.getByLabelText('发件人地址'), {
323
+ target: { value: 'noreply@example.com' },
324
+ });
325
+ fireEvent.click(screen.getByRole('button', { name: '保存 SMTP 配置' }));
326
+
327
+ await waitFor(() => expect(mockedPut).toHaveBeenCalledTimes(5));
328
+ expect(mockedPut).toHaveBeenCalledWith('/api/admin/settings/smtp_host', {
329
+ value: 'smtp2.example.com',
330
+ });
331
+ expect(mockedPut).toHaveBeenCalledWith('/api/admin/settings/smtp_port', {
332
+ value: '587',
333
+ });
334
+ expect(mockedPut).toHaveBeenCalledWith('/api/admin/settings/smtp_user', {
335
+ value: 'api2',
336
+ });
337
+ expect(mockedPut).toHaveBeenCalledWith('/api/admin/settings/smtp_pass', {
338
+ value: 's3cret',
339
+ });
340
+ expect(mockedPut).toHaveBeenCalledWith('/api/admin/settings/smtp_from', {
341
+ value: 'noreply@example.com',
342
+ });
343
+ });
344
+
345
+ it('空 pass 跳过提交:密码留空保存 → 只 PUT 其余 4 键,绝不提交空/掩码密码', async () => {
346
+ renderWithSmtp();
347
+ await screen.findByText('SMTP 发信配置');
348
+
349
+ // 不动密码框(state 恒空),直接保存其余四键
350
+ fireEvent.click(screen.getByRole('button', { name: '保存 SMTP 配置' }));
351
+
352
+ await waitFor(() => expect(mockedPut).toHaveBeenCalledTimes(4));
353
+ expect(mockedPut).toHaveBeenCalledWith('/api/admin/settings/smtp_host', {
354
+ value: 'smtp.example.com',
355
+ });
356
+ expect(mockedPut).toHaveBeenCalledWith('/api/admin/settings/smtp_from', {
357
+ value: 'no-reply@example.com',
358
+ });
359
+ expect(mockedPut).not.toHaveBeenCalledWith(
360
+ '/api/admin/settings/smtp_pass',
361
+ expect.anything(),
362
+ );
363
+ });
364
+ });
365
+
366
+ describe('站长验证卡(UP-2)', () => {
367
+ it('空值显示未配置徽章;粘贴完整 meta 标签保存 → PUT site_verification_meta 原文', async () => {
368
+ mockedGet.mockResolvedValueOnce({
369
+ ...ENVELOPE,
370
+ effective: {
371
+ ...ENVELOPE.effective,
372
+ site_verification_meta: '',
373
+ },
374
+ });
375
+ renderPage();
376
+ expect(
377
+ await screen.findByText('搜索引擎站点验证'),
378
+ ).toBeInTheDocument();
379
+ expect(screen.getByText('未配置')).toBeInTheDocument();
380
+
381
+ fireEvent.change(screen.getByLabelText(/验证标记/), {
382
+ target: {
383
+ value:
384
+ '<meta name="google-site-verification" content="abc123XYZ" />',
385
+ },
386
+ });
387
+ // 有非空值即点亮"已注入"(同公告卡"展示中"的即时徽章逻辑)
388
+ expect(screen.getByText('已注入')).toBeInTheDocument();
389
+
390
+ fireEvent.click(
391
+ screen.getByRole('button', { name: '保存验证配置' }),
392
+ );
393
+ await waitFor(() => expect(mockedPut).toHaveBeenCalledTimes(1));
394
+ expect(mockedPut).toHaveBeenCalledWith(
395
+ '/api/admin/settings/site_verification_meta',
396
+ {
397
+ value: '<meta name="google-site-verification" content="abc123XYZ" />',
398
+ },
399
+ );
400
+ });
401
+ });
402
+
403
+ describe('OAuth 只读卡(UP-6)', () => {
404
+ const OAUTH = [
405
+ {
406
+ name: 'google',
407
+ callback_url: 'https://example.com/auth/oauth/google/callback',
408
+ },
409
+ {
410
+ name: 'github',
411
+ callback_url: 'https://example.com/auth/oauth/github/callback',
412
+ },
413
+ ];
414
+
415
+ it('有 providers → 渲染名称徽章 + mono 回调地址 + 复制按钮 + 反代转发提示', async () => {
416
+ mockedGet.mockResolvedValueOnce({ ...ENVELOPE, oauth: OAUTH });
417
+ renderPage();
418
+ expect(
419
+ await screen.findByText('OAuth 第三方登录'),
420
+ ).toBeInTheDocument();
421
+ expect(screen.getByText('google')).toBeInTheDocument();
422
+ expect(screen.getByText('github')).toBeInTheDocument();
423
+ expect(
424
+ screen.getByText('https://example.com/auth/oauth/google/callback'),
425
+ ).toBeInTheDocument();
426
+ expect(
427
+ screen.getByRole('button', { name: '复制 google 回调地址' }),
428
+ ).toBeInTheDocument();
429
+ expect(
430
+ screen.getByText(/按站点 base URL 推算的期望值/),
431
+ ).toBeInTheDocument();
432
+ expect(screen.getByText(/X-Forwarded-Proto/)).toBeInTheDocument();
433
+ });
434
+
435
+ it('复制按钮 → clipboard.writeText + toast 反馈', async () => {
436
+ const writeText = vi.fn().mockResolvedValue(undefined);
437
+ vi.stubGlobal('navigator', { clipboard: { writeText } });
438
+ mockedGet.mockResolvedValueOnce({
439
+ ...ENVELOPE,
440
+ oauth: [OAUTH[0]],
441
+ });
442
+ renderPage();
443
+ await screen.findByText('OAuth 第三方登录');
444
+ fireEvent.click(
445
+ screen.getByRole('button', { name: '复制 google 回调地址' }),
446
+ );
447
+ await waitFor(() =>
448
+ expect(writeText).toHaveBeenCalledWith(
449
+ 'https://example.com/auth/oauth/google/callback',
450
+ ),
451
+ );
452
+ await waitFor(() =>
453
+ expect(toastMocks.success).toHaveBeenCalledWith(
454
+ 'google 回调地址已复制',
455
+ ),
456
+ );
457
+ vi.unstubAllGlobals();
458
+ });
459
+
460
+ it('空数组 → 渲染空态提示(用 GOOGLE/GITHUB_CLIENT_ID/SECRET 环境变量启用)', async () => {
461
+ mockedGet.mockResolvedValueOnce({ ...ENVELOPE, oauth: [] });
462
+ renderPage();
463
+ await screen.findByText('OAuth 第三方登录');
464
+ expect(screen.getByText('未启用')).toBeInTheDocument();
465
+ expect(screen.getByText(/GOOGLE_CLIENT_ID/)).toBeInTheDocument();
466
+ expect(screen.getByText(/GITHUB_CLIENT_ID/)).toBeInTheDocument();
467
+ });
468
+
469
+ it('信封无 oauth 字段 → 整卡不渲染(后端未接线时静默降级)', async () => {
470
+ renderPage();
471
+ await screen.findByText('顶部公告横条');
472
+ expect(screen.queryByText('OAuth 第三方登录')).not.toBeInTheDocument();
473
+ expect(screen.queryByText('登录方式')).not.toBeInTheDocument();
474
+ expect(
475
+ screen.queryByRole('button', { name: /复制.*回调地址/ }),
476
+ ).not.toBeInTheDocument();
477
+ });
478
+ });
260
479
  });
@@ -0,0 +1,214 @@
1
+
2
+ import '@testing-library/jest-dom/vitest';
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+
7
+ const mockedGet = vi.hoisted(() => vi.fn());
8
+ const mockedPatch = vi.hoisted(() => vi.fn());
9
+ vi.mock('@ploykit/client', () => ({
10
+ api: {
11
+ get: mockedGet,
12
+ put: vi.fn(),
13
+ post: vi.fn(),
14
+ patch: mockedPatch,
15
+ delete: vi.fn(),
16
+ },
17
+ ApiError: class ApiError extends Error {
18
+ status: number;
19
+ code: string;
20
+ details?: unknown;
21
+ constructor(
22
+ status: number,
23
+ code: string,
24
+ message: string,
25
+ details?: unknown,
26
+ ) {
27
+ super(message);
28
+ this.status = status;
29
+ this.code = code;
30
+ this.details = details;
31
+ }
32
+ },
33
+ }));
34
+
35
+ import { ApiError } from '@ploykit/client';
36
+ import { PlansPage } from '../PlansPage';
37
+
38
+ const PLANS = {
39
+ items: ['free', 'pro'],
40
+ plans: [
41
+ {
42
+ code: 'free',
43
+ name: 'Free',
44
+ currency: 'CNY',
45
+ trial_days: 0,
46
+ sort_no: 1,
47
+ limits: { workspaces: 1, tasks_monthly: 100 },
48
+ },
49
+ {
50
+ code: 'pro',
51
+ name: 'Pro',
52
+ currency: 'USD',
53
+ trial_days: 14,
54
+ sort_no: 2,
55
+ limits: { workspaces: -1, price_monthly_cents: 990 },
56
+ },
57
+ ],
58
+ };
59
+
60
+ function renderPage() {
61
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
62
+ return render(
63
+ <QueryClientProvider client={qc}>
64
+ <PlansPage />
65
+ </QueryClientProvider>,
66
+ );
67
+ }
68
+
69
+ function expandPro() {
70
+ fireEvent.click(screen.getByRole('button', { name: '编辑套餐 pro' }));
71
+ }
72
+
73
+ beforeEach(() => {
74
+ mockedGet.mockReset().mockImplementation(async (path: string) => {
75
+ if (path.startsWith('/api/admin/plans')) return PLANS;
76
+ throw new Error('unexpected GET ' + path);
77
+ });
78
+ mockedPatch.mockReset().mockResolvedValue(PLANS.plans[1]);
79
+ });
80
+
81
+ describe('PlansPage(UP-5 套餐编辑)', () => {
82
+ it('渲染套餐列表:free/pro 两卡折叠态(名称/代码/币种/排序可见,表单未挂载)', async () => {
83
+ renderPage();
84
+ expect(await screen.findByText('Free')).toBeInTheDocument();
85
+ expect(screen.getByText('Pro')).toBeInTheDocument();
86
+ expect(screen.getByText('free')).toBeInTheDocument();
87
+ expect(screen.getByText('pro')).toBeInTheDocument();
88
+ expect(screen.getByText('CNY')).toBeInTheDocument();
89
+ expect(screen.getByText('USD')).toBeInTheDocument();
90
+ // 折叠态:编辑表单不存在
91
+ expect(screen.queryByLabelText('名称')).not.toBeInTheDocument();
92
+ expect(
93
+ screen.queryByRole('button', { name: '保存' }),
94
+ ).not.toBeInTheDocument();
95
+ });
96
+
97
+ it('展开 pro 编辑保存 → PATCH /api/admin/plans/pro 全量 body;-1 显示 Unlimited 提示', async () => {
98
+ renderPage();
99
+ await screen.findByText('Pro');
100
+ expandPro();
101
+
102
+ // workspaces: -1 → Unlimited(无限)提示;price_monthly_cents → 价格(单位:分)
103
+ expect(await screen.findByText('Unlimited(无限)')).toBeInTheDocument();
104
+ expect(screen.getByText('价格(单位:分)')).toBeInTheDocument();
105
+ // workspaces 是 billing 未登记键 → 高级键弱提示
106
+ expect(screen.getAllByText('高级键')).toHaveLength(1);
107
+
108
+ fireEvent.change(screen.getByLabelText('名称'), {
109
+ target: { value: '专业版' },
110
+ });
111
+ fireEvent.click(screen.getByRole('button', { name: '保存' }));
112
+
113
+ await waitFor(() =>
114
+ expect(mockedPatch).toHaveBeenCalledWith('/api/admin/plans/pro', {
115
+ name: '专业版',
116
+ currency: 'USD',
117
+ sort_no: 2,
118
+ limits: { workspaces: -1, price_monthly_cents: 990 },
119
+ }),
120
+ );
121
+ });
122
+
123
+ it('保存 404(plan 不存在)→ 就地展示后端消息', async () => {
124
+ mockedPatch.mockRejectedValueOnce(
125
+ new ApiError(404, 'not_found', 'plan not found'),
126
+ );
127
+ renderPage();
128
+ await screen.findByText('Pro');
129
+ expandPro();
130
+ fireEvent.click(await screen.findByRole('button', { name: '保存' }));
131
+
132
+ expect(await screen.findByRole('alert')).toHaveTextContent(
133
+ '不存在或无权访问',
134
+ );
135
+ });
136
+
137
+ it('保存 400(校验失败)→ 就地展示后端校验消息', async () => {
138
+ mockedPatch.mockRejectedValueOnce(
139
+ new ApiError(
140
+ 400,
141
+ 'E_VALIDATION',
142
+ 'validation failed',
143
+ 'currency: must be 3 uppercase letters',
144
+ ),
145
+ );
146
+ renderPage();
147
+ await screen.findByText('Pro');
148
+ expandPro();
149
+ fireEvent.click(await screen.findByRole('button', { name: '保存' }));
150
+
151
+ expect(await screen.findByRole('alert')).toHaveTextContent(
152
+ 'currency: must be 3 uppercase letters',
153
+ );
154
+ });
155
+
156
+ it('limits 加行:添加 credits=100 并保存 → body 含新键;未知键给高级键提示', async () => {
157
+ renderPage();
158
+ await screen.findByText('Pro');
159
+ expandPro();
160
+
161
+ const keys = () => screen.getAllByLabelText('限制键名');
162
+ expect(await screen.findAllByLabelText('限制键名')).toHaveLength(2);
163
+
164
+ fireEvent.click(screen.getByRole('button', { name: '添加限制键' }));
165
+ expect(keys()).toHaveLength(3);
166
+
167
+ fireEvent.change(keys()[2], { target: { value: 'credits' } });
168
+ fireEvent.change(screen.getAllByLabelText('限制值')[2], {
169
+ target: { value: '100' },
170
+ });
171
+ // workspaces + credits 两个未登记键 → 两个高级键提示
172
+ expect(screen.getAllByText('高级键')).toHaveLength(2);
173
+
174
+ fireEvent.click(screen.getByRole('button', { name: '保存' }));
175
+ await waitFor(() =>
176
+ expect(mockedPatch).toHaveBeenCalledWith('/api/admin/plans/pro', {
177
+ name: 'Pro',
178
+ currency: 'USD',
179
+ sort_no: 2,
180
+ limits: { workspaces: -1, price_monthly_cents: 990, credits: 100 },
181
+ }),
182
+ );
183
+ });
184
+
185
+ it('limits 减行:删除空行与 workspaces 行后保存 → body 不再含该键', async () => {
186
+ renderPage();
187
+ await screen.findByText('Pro');
188
+ expandPro();
189
+
190
+ const keys = () => screen.getAllByLabelText('限制键名');
191
+ expect(await screen.findAllByLabelText('限制键名')).toHaveLength(2);
192
+
193
+ // 误触加行再删行:整行留空不阻塞保存,也不进 body
194
+ fireEvent.click(screen.getByRole('button', { name: '添加限制键' }));
195
+ expect(keys()).toHaveLength(3);
196
+ fireEvent.click(screen.getByRole('button', { name: '删除限制 3' }));
197
+ expect(keys()).toHaveLength(2);
198
+
199
+ fireEvent.click(
200
+ screen.getByRole('button', { name: '删除限制 workspaces' }),
201
+ );
202
+ expect(keys()).toHaveLength(1);
203
+
204
+ fireEvent.click(screen.getByRole('button', { name: '保存' }));
205
+ await waitFor(() =>
206
+ expect(mockedPatch).toHaveBeenCalledWith('/api/admin/plans/pro', {
207
+ name: 'Pro',
208
+ currency: 'USD',
209
+ sort_no: 2,
210
+ limits: { price_monthly_cents: 990 },
211
+ }),
212
+ );
213
+ });
214
+ });
@@ -123,7 +123,10 @@ export function BillingPage({ channel = 'stripe' }: BillingPageProps = {}) {
123
123
  : null
124
124
  const orders: PKOrder[] = subscription?.orders ?? []
125
125
  const currentPlanCode = subscription?.plan_code ?? null
126
- const sortedPlans = [...plans].sort((a, b) => a.sort_no - b.sort_no)
126
+ // UP-8/D4 (docs/plan-upstream-shuijing-admin-billing.md): filter by price-key capability, not code-prefix — a plan with neither price_monthly_cents nor price_yearly_cents (one_time pack) must not render as a ¥0 subscription card.
127
+ const sortedPlans = [...plans]
128
+ .filter((p) => p.limits.price_monthly_cents !== undefined || p.limits.price_yearly_cents !== undefined)
129
+ .sort((a, b) => a.sort_no - b.sort_no)
127
130
 
128
131
 
129
132
 
@@ -286,6 +289,10 @@ export function BillingPage({ channel = 'stripe' }: BillingPageProps = {}) {
286
289
  </p>
287
290
  )}
288
291
 
292
+ {sortedPlans.length === 0 && (
293
+ <PageEmpty label="暂无可订阅套餐" hint="站点尚未配置按月或按年订阅的套餐。" />
294
+ )}
295
+
289
296
  <div className="grid gap-4 lg:grid-cols-3">
290
297
  {sortedPlans.map((plan) => {
291
298
  const isCurrent = plan.code === currentPlanCode
@@ -66,10 +66,10 @@ function SearchProbe() {
66
66
  return <span data-testid="search-params">{sp.toString()}</span>
67
67
  }
68
68
 
69
- function renderPage(initialUrl = '/billing') {
69
+ function renderPage(initialUrl = '/billing', plansOverride?: typeof plans) {
70
70
  billingMocks.useBilling.mockReturnValue({
71
71
  subscription: { plan_code: 'free', orders },
72
- plans,
72
+ plans: plansOverride ?? plans,
73
73
  loading: false,
74
74
  refresh: vi.fn(),
75
75
  })
@@ -228,6 +228,36 @@ describe('BillingPage', () => {
228
228
  })
229
229
  })
230
230
 
231
+ describe('BillingPage UP-8:one_time 套餐过滤(D4 price-key capability,非 code 前缀)', () => {
232
+ const packOnly = { code: 'pack_100', name: '积分包 100', limits: { tasks_monthly: 100, price_one_time_cents: 990 }, sort_no: 2 }
233
+
234
+ it('仅含 price_one_time_cents 的 plan 不渲染进订阅网格(capability 判据,不看 code 前缀)', () => {
235
+ renderPage('/billing', [
236
+ { code: 'free', name: 'Free', limits: { tasks_monthly: 100, price_monthly_cents: 0, price_yearly_cents: 0 }, sort_no: 1 },
237
+ { code: 'bundle_200', name: 'Bundle 200', limits: { tasks_monthly: 200, price_one_time_cents: 1990 }, sort_no: 3 },
238
+ packOnly,
239
+ ])
240
+ expect(screen.getByText('Free')).toBeInTheDocument()
241
+ expect(screen.queryByText('积分包 100')).not.toBeInTheDocument()
242
+ expect(screen.queryByText('Bundle 200')).not.toBeInTheDocument()
243
+ })
244
+
245
+ it('monthly + one_time 双价 plan 照常渲染月付卡(价格取订阅键)', () => {
246
+ renderPage('/billing', [
247
+ { code: 'pro', name: 'Pro', limits: { tasks_monthly: 10000, price_monthly_cents: 990, price_yearly_cents: 9900, price_one_time_cents: 499 }, sort_no: 1 },
248
+ ])
249
+ expect(screen.getByText('Pro')).toBeInTheDocument()
250
+ expect(screen.getByText('¥9.90')).toBeInTheDocument()
251
+ expect(screen.getByRole('button', { name: '升级' })).toBeEnabled()
252
+ })
253
+
254
+ it('全部 plan 被过滤(仅 one_time 包)→ 空态成立', () => {
255
+ renderPage('/billing', [packOnly])
256
+ expect(screen.getByText('暂无可订阅套餐')).toBeInTheDocument()
257
+ expect(screen.queryByText('积分包 100')).not.toBeInTheDocument()
258
+ })
259
+ })
260
+
231
261
  describe('BillingPage B1/G4:manual 渠道接线(渠道表动态生成)', () => {
232
262
  it('/config 只注册 manual → 渠道表仅含 manual,未注册渠道不出现;升级可用且 POST channel=manual', async () => {
233
263
  clientGet.mockResolvedValue({ billing_channels: ['manual'] })