@ploykit/ui 0.6.0 → 0.6.2

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 (222) hide show
  1. package/dist/AppProviders.js +2 -2
  2. package/dist/components/ConfirmDialog.js +1 -1
  3. package/dist/components/CronInput.js +3 -3
  4. package/dist/components/DataTable.js +4 -4
  5. package/dist/components/FormField.js +1 -1
  6. package/dist/components/ImpersonationBanner.js +1 -1
  7. package/dist/components/NotificationBell.js +1 -1
  8. package/dist/components/OnboardingChecklist.js +3 -3
  9. package/dist/components/Page.js +2 -2
  10. package/dist/components/ReauthDialog.js +4 -4
  11. package/dist/components/SecretModal.js +2 -2
  12. package/dist/components/SettingsCard.js +2 -2
  13. package/dist/components/SiteBanner.js +1 -1
  14. package/dist/components/SiteBannerLayer.js +1 -1
  15. package/dist/components/StatusBadge.js +1 -1
  16. package/dist/components/UserMenu.js +3 -3
  17. package/dist/components/WorkspaceSwitcher.js +7 -7
  18. package/dist/components/ui/alert-dialog.js +2 -2
  19. package/dist/components/ui/avatar.js +1 -1
  20. package/dist/components/ui/badge.js +1 -1
  21. package/dist/components/ui/breadcrumb.js +1 -1
  22. package/dist/components/ui/button.js +1 -1
  23. package/dist/components/ui/card.js +1 -1
  24. package/dist/components/ui/checkbox.js +1 -1
  25. package/dist/components/ui/dialog.js +2 -2
  26. package/dist/components/ui/dropdown-menu.js +1 -1
  27. package/dist/components/ui/empty.js +1 -1
  28. package/dist/components/ui/field.js +3 -3
  29. package/dist/components/ui/input.js +1 -1
  30. package/dist/components/ui/label.js +1 -1
  31. package/dist/components/ui/popover.js +1 -1
  32. package/dist/components/ui/progress.js +1 -1
  33. package/dist/components/ui/select.js +1 -1
  34. package/dist/components/ui/separator.js +1 -1
  35. package/dist/components/ui/sheet.js +2 -2
  36. package/dist/components/ui/sidebar.js +8 -8
  37. package/dist/components/ui/skeleton.js +1 -1
  38. package/dist/components/ui/switch.js +1 -1
  39. package/dist/components/ui/tabs.js +1 -1
  40. package/dist/components/ui/textarea.js +1 -1
  41. package/dist/components/ui/tooltip.js +1 -1
  42. package/dist/index.js +78 -78
  43. package/dist/layouts/AppShell.js +4 -4
  44. package/dist/layouts/SettingsShell.js +4 -4
  45. package/dist/pages/LoginPage.js +5 -5
  46. package/dist/pages/account/NotificationPreferencesPage.js +3 -3
  47. package/dist/pages/account/ProfileSettingsPage.js +9 -9
  48. package/dist/pages/account/SecuritySettingsPage.js +13 -13
  49. package/dist/pages/account/TokensPage.js +12 -12
  50. package/dist/pages/admin/AdminSettingsPage.js +11 -11
  51. package/dist/pages/admin/AdminShell.js +3 -3
  52. package/dist/pages/admin/AuditPage.js +8 -8
  53. package/dist/pages/admin/OrdersPage.js +12 -12
  54. package/dist/pages/admin/OverviewPage.js +13 -13
  55. package/dist/pages/admin/SSOPage.js +6 -6
  56. package/dist/pages/admin/UserDetailPage.js +13 -13
  57. package/dist/pages/admin/UsersPage.js +13 -13
  58. package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
  59. package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
  60. package/dist/pages/admin/WorkspacesPage.js +9 -9
  61. package/dist/pages/admin/shared.js +1 -1
  62. package/dist/pages/auth/ForgotPasswordPage.js +4 -4
  63. package/dist/pages/auth/InviteAcceptPage.js +6 -6
  64. package/dist/pages/auth/RegisterPage.js +7 -7
  65. package/dist/pages/auth/ResetPasswordPage.js +5 -5
  66. package/dist/pages/auth/VerifyEmailPage.js +3 -3
  67. package/dist/pages/workspace/BillingPage.js +11 -11
  68. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  69. package/dist/pages/workspace/MembersPage.js +15 -15
  70. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  71. package/dist/pages/workspace/SchedulesPage.js +12 -12
  72. package/dist/pages/workspace/UsagePage.js +6 -6
  73. package/dist/pages/workspace/WebhooksPage.js +16 -16
  74. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  75. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  76. package/package.json +6 -5
  77. package/src/AppProviders.tsx +29 -0
  78. package/src/components/ConfirmDialog.tsx +77 -0
  79. package/src/components/CronInput.tsx +195 -0
  80. package/src/components/DataTable.tsx +152 -0
  81. package/src/components/ErrorBoundary.tsx +46 -0
  82. package/src/components/FormField.tsx +56 -0
  83. package/src/components/ImpersonationBanner.tsx +23 -0
  84. package/src/components/NotificationBell.tsx +135 -0
  85. package/src/components/OnboardingChecklist.tsx +114 -0
  86. package/src/components/Page.tsx +67 -0
  87. package/src/components/ReauthDialog.tsx +94 -0
  88. package/src/components/SecretModal.tsx +147 -0
  89. package/src/components/SettingsCard.tsx +39 -0
  90. package/src/components/SiteBanner.tsx +32 -0
  91. package/src/components/SiteBannerLayer.tsx +29 -0
  92. package/src/components/StatusBadge.tsx +12 -0
  93. package/src/components/UserMenu.tsx +103 -0
  94. package/src/components/WorkspaceSwitcher.tsx +159 -0
  95. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  96. package/src/components/__tests__/CronInput.test.tsx +180 -0
  97. package/src/components/__tests__/DataTable.test.tsx +208 -0
  98. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  99. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  100. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  101. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  102. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  103. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  104. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  105. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  106. package/src/components/__tests__/form.test.tsx +133 -0
  107. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  108. package/src/components/__tests__/smoke.test.tsx +11 -0
  109. package/src/components/__tests__/toast.test.tsx +27 -0
  110. package/src/components/toast.tsx +23 -0
  111. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  112. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  113. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  114. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  115. package/src/components/ui/__tests__/field.test.tsx +43 -0
  116. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  117. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  118. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  119. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  120. package/src/components/ui/__tests__/select.test.tsx +48 -0
  121. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  122. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  123. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  124. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  125. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  126. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  127. package/src/components/ui/alert-dialog.tsx +187 -0
  128. package/src/components/ui/avatar.tsx +108 -0
  129. package/src/components/ui/badge.tsx +51 -0
  130. package/src/components/ui/breadcrumb.tsx +124 -0
  131. package/src/components/ui/button.tsx +57 -0
  132. package/src/components/ui/card.tsx +102 -0
  133. package/src/components/ui/checkbox.tsx +28 -0
  134. package/src/components/ui/dialog.tsx +166 -0
  135. package/src/components/ui/dropdown-menu.tsx +265 -0
  136. package/src/components/ui/empty.tsx +103 -0
  137. package/src/components/ui/field.tsx +238 -0
  138. package/src/components/ui/input.tsx +19 -0
  139. package/src/components/ui/label.tsx +17 -0
  140. package/src/components/ui/popover.tsx +89 -0
  141. package/src/components/ui/progress.tsx +82 -0
  142. package/src/components/ui/select.tsx +235 -0
  143. package/src/components/ui/separator.tsx +24 -0
  144. package/src/components/ui/sheet.tsx +146 -0
  145. package/src/components/ui/sidebar.tsx +723 -0
  146. package/src/components/ui/skeleton.tsx +13 -0
  147. package/src/components/ui/switch.tsx +29 -0
  148. package/src/components/ui/tabs.tsx +79 -0
  149. package/src/components/ui/textarea.tsx +26 -0
  150. package/src/components/ui/tooltip.tsx +63 -0
  151. package/src/dev.css +1 -0
  152. package/src/hooks/use-mobile.ts +19 -0
  153. package/src/index.ts +150 -0
  154. package/src/layouts/AppShell.tsx +163 -0
  155. package/src/layouts/AuthShell.tsx +24 -0
  156. package/src/layouts/SettingsShell.tsx +129 -0
  157. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  158. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  159. package/src/lib/__tests__/api-error.test.ts +163 -0
  160. package/src/lib/__tests__/utils.test.ts +21 -0
  161. package/src/lib/api-error.ts +98 -0
  162. package/src/lib/utils.ts +109 -0
  163. package/src/pages/LandingPage.tsx +88 -0
  164. package/src/pages/LoginPage.tsx +291 -0
  165. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  166. package/src/pages/__tests__/LoginPage.test.tsx +229 -0
  167. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  168. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  169. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  170. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  171. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  172. package/src/pages/account/TokensPage.tsx +211 -0
  173. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  174. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  175. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  176. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  177. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  178. package/src/pages/admin/AdminShell.tsx +199 -0
  179. package/src/pages/admin/AuditPage.tsx +338 -0
  180. package/src/pages/admin/OrdersPage.tsx +642 -0
  181. package/src/pages/admin/OverviewPage.tsx +559 -0
  182. package/src/pages/admin/SSOPage.tsx +166 -0
  183. package/src/pages/admin/UserDetailPage.tsx +641 -0
  184. package/src/pages/admin/UsersPage.tsx +462 -0
  185. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  186. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  187. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  188. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  189. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  190. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  191. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  192. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  193. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  194. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  195. package/src/pages/admin/shared.ts +42 -0
  196. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  197. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  198. package/src/pages/auth/RegisterPage.tsx +204 -0
  199. package/src/pages/auth/ResetPasswordPage.tsx +150 -0
  200. package/src/pages/auth/VerifyEmailPage.tsx +76 -0
  201. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  202. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  203. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  204. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  205. package/src/pages/workspace/BillingPage.tsx +361 -0
  206. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  207. package/src/pages/workspace/MembersPage.tsx +568 -0
  208. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  209. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  210. package/src/pages/workspace/UsagePage.tsx +207 -0
  211. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  212. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  213. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  214. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  215. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  216. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  217. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  218. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  219. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  220. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  221. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  222. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,302 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { MemoryRouter, useSearchParams } from 'react-router'
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
6
+ import { BillingPage } from '../BillingPage'
7
+
8
+ const billingMocks = vi.hoisted(() => ({ useBilling: vi.fn() }))
9
+ vi.mock('../../../../../hooks/src/hooks/useBilling', () => ({ useBilling: billingMocks.useBilling }))
10
+
11
+ vi.mock('../../../../../hooks/src/hooks/useWorkspace', () => ({
12
+ useWorkspace: () => ({
13
+ current: { id: 'ws-1', name: 'Acme', slug: 'acme', plan_code: 'free', role: 'owner' },
14
+ switchTo: vi.fn(),
15
+ clear: vi.fn(),
16
+ }),
17
+ }))
18
+
19
+ const apiMocks = vi.hoisted(() => ({
20
+ get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(),
21
+ }))
22
+ vi.mock('../../../../../hooks/src/hooks/useApi', () => ({ useApi: () => apiMocks }))
23
+
24
+
25
+
26
+
27
+ const clientGet = vi.hoisted(() => vi.fn())
28
+ vi.mock('@ploykit/client', () => {
29
+ class ApiError extends Error {
30
+ status: number
31
+ code: string
32
+ constructor(status: number, code: string, message: string) {
33
+ super(message)
34
+ this.status = status
35
+ this.code = code
36
+ }
37
+ }
38
+ return {
39
+ api: { get: clientGet, post: vi.fn() },
40
+ ApiError,
41
+ setWorkspaceIdProvider: vi.fn(),
42
+ }
43
+ })
44
+
45
+ const toastMocks = vi.hoisted(() => ({
46
+ success: vi.fn(), error: vi.fn(), info: vi.fn(),
47
+ }))
48
+ vi.mock('../../../components/toast', () => ({ toast: toastMocks }))
49
+
50
+
51
+ const confirmSpy = vi.hoisted(() => vi.fn(async () => true))
52
+ vi.mock('../../../components/ConfirmDialog', () => ({ useConfirm: () => confirmSpy }))
53
+
54
+ const plans = [
55
+ { code: 'free', name: 'Free', limits: { tasks_monthly: 100, price_monthly_cents: 0, price_yearly_cents: 0 }, sort_no: 1 },
56
+ { code: 'pro', name: 'Pro', limits: { tasks_monthly: 10000, price_monthly_cents: 990, price_yearly_cents: 9900 }, sort_no: 2 },
57
+ ]
58
+ const orders = [
59
+ { id: 'o-1', plan_code: 'pro', interval: 'yearly', amount_cents: 990, currency: 'usd', status: 'pending', created_at: '2026-10-01T00:00:00Z' },
60
+ { id: 'o-2', plan_code: 'starter', interval: 'yearly', amount_cents: 4900, currency: 'usd', status: 'paid', created_at: '2026-09-01T00:00:00Z' },
61
+ ]
62
+
63
+
64
+ function SearchProbe() {
65
+ const [sp] = useSearchParams()
66
+ return <span data-testid="search-params">{sp.toString()}</span>
67
+ }
68
+
69
+ function renderPage(initialUrl = '/billing') {
70
+ billingMocks.useBilling.mockReturnValue({
71
+ subscription: { plan_code: 'free', orders },
72
+ plans,
73
+ loading: false,
74
+ refresh: vi.fn(),
75
+ })
76
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
77
+ return render(
78
+ <QueryClientProvider client={qc}>
79
+ <MemoryRouter initialEntries={[initialUrl]}>
80
+ <BillingPage />
81
+ <SearchProbe />
82
+ </MemoryRouter>
83
+ </QueryClientProvider>,
84
+ )
85
+ }
86
+
87
+ beforeEach(() => {
88
+ vi.clearAllMocks()
89
+ apiMocks.post.mockResolvedValue({ action_url: 'https://checkout.example.com/s/1' })
90
+
91
+
92
+ apiMocks.get.mockImplementation(async (url: string) => {
93
+ if (url === '/api/billing/subscription') return { plan_code: 'free', orders: [] }
94
+ if (url === '/api/billing/plans') return []
95
+ throw new Error('unexpected GET ' + url)
96
+ })
97
+
98
+ clientGet.mockResolvedValue({})
99
+ })
100
+
101
+ describe('BillingPage', () => {
102
+ it('渲染当前套餐卡、套餐卡与订单表', () => {
103
+ renderPage()
104
+ expect(screen.getByText('当前套餐')).toBeInTheDocument()
105
+ expect(screen.getByText(/共 2 笔订单(待处理 1 笔)/)).toBeInTheDocument()
106
+ expect(screen.getByText('Free')).toBeInTheDocument()
107
+ expect(screen.getByText('Pro')).toBeInTheDocument()
108
+
109
+ expect(screen.getAllByText('¥9.90').length).toBe(1)
110
+ expect(screen.getByText(/USD 9\.90/)).toBeInTheDocument()
111
+ expect(screen.getByText(/USD 49\.00/)).toBeInTheDocument()
112
+ expect(screen.getByRole('button', { name: '当前计划' })).toBeDisabled()
113
+
114
+ expect(screen.getAllByText('pro').length).toBeGreaterThan(0)
115
+ expect(screen.getByRole('button', { name: '取消' })).toBeInTheDocument()
116
+ })
117
+
118
+ it('B9:价格单位"/月"两侧空格平衡(此前"¥999/月"失衡)', () => {
119
+ renderPage()
120
+ expect(
121
+ screen.getByText((_, el) => el?.tagName === 'SPAN' && el.textContent === ' / 月'),
122
+ ).toBeInTheDocument()
123
+ })
124
+
125
+ it('P2-6:订阅/套餐加载失败 → PageError 错误态(不再静默空页谎报空数据)', async () => {
126
+ apiMocks.get.mockReset().mockRejectedValue(new Error('网络连接失败'))
127
+ renderPage()
128
+
129
+ const alert = await screen.findByRole('alert')
130
+ expect(alert).toHaveTextContent('网络连接失败')
131
+ expect(screen.queryByText('选择套餐')).not.toBeInTheDocument()
132
+
133
+ expect(screen.queryByText('暂无订单')).not.toBeInTheDocument()
134
+ expect(screen.queryByText(/共 \d+ 笔订单/)).not.toBeInTheDocument()
135
+ })
136
+
137
+ it('P2-6 补强:仅套餐失败(订阅正常)→ 仍进错误态,不按空套餐渲染', async () => {
138
+ apiMocks.get.mockImplementation(async (url: string) => {
139
+ if (url === '/api/billing/subscription') return { plan_code: 'free', orders: [] }
140
+ throw new Error('套餐接口 500')
141
+ })
142
+ renderPage()
143
+ const alert = await screen.findByRole('alert')
144
+ expect(alert).toHaveTextContent('套餐接口 500')
145
+ expect(screen.queryByText('选择套餐')).not.toBeInTheDocument()
146
+ })
147
+
148
+ it('升级 CTA 提交 POST /api/billing/checkout 并跳转 action_url', async () => {
149
+ renderPage()
150
+ fireEvent.click(screen.getByRole('button', { name: '升级' }))
151
+ await waitFor(() =>
152
+ expect(apiMocks.post).toHaveBeenCalledWith('/api/billing/checkout', {
153
+ plan_code: 'pro', interval: 'monthly', channel: 'stripe',
154
+ }),
155
+ )
156
+ })
157
+
158
+ it('B7:Stripe 专属跳回文案仅在 Stripe 渠道下显示', () => {
159
+ renderPage()
160
+ expect(
161
+ screen.getByText('升级立即生效;Stripe 结账完成后自动跳回。'),
162
+ ).toBeInTheDocument()
163
+ })
164
+
165
+ it('interval 切换到按年 → 价格取 price_yearly_cents', async () => {
166
+ renderPage()
167
+ fireEvent.click(screen.getByRole('button', { name: '按年' }))
168
+ await waitFor(() => expect(screen.getByText('¥99.00')).toBeInTheDocument())
169
+ fireEvent.click(screen.getByRole('button', { name: '升级' }))
170
+ await waitFor(() =>
171
+ expect(apiMocks.post).toHaveBeenCalledWith(
172
+ '/api/billing/checkout',
173
+ expect.objectContaining({ interval: 'yearly' }),
174
+ ),
175
+ )
176
+ })
177
+
178
+ it('B6:周期选择写入 URL searchParams', async () => {
179
+ renderPage('/billing')
180
+ expect(screen.getByTestId('search-params').textContent).toBe('')
181
+ fireEvent.click(screen.getByRole('button', { name: '按年' }))
182
+ await waitFor(() =>
183
+ expect(screen.getByTestId('search-params').textContent).toBe('interval=yearly'),
184
+ )
185
+ })
186
+
187
+ it('B6:渠道选择写入 URL searchParams', async () => {
188
+ clientGet.mockResolvedValue({ billing_channels: ['stripe', 'alipay', 'manual'] })
189
+ renderPage('/billing')
190
+ fireEvent.click(await screen.findByRole('combobox', { name: '支付渠道' }))
191
+ const option = await screen.findByRole('option', { name: '支付宝' })
192
+ fireEvent.pointerDown(option, { button: 0, pointerType: 'mouse' })
193
+ fireEvent.pointerUp(option, { button: 0, pointerType: 'mouse' })
194
+ fireEvent.click(option)
195
+ await waitFor(() =>
196
+ expect(screen.getByTestId('search-params').textContent).toContain('channel=alipay'),
197
+ )
198
+ })
199
+
200
+ it('B6:URL 带参进入 → 周期/渠道按参数初始化', async () => {
201
+ clientGet.mockResolvedValue({ billing_channels: ['stripe', 'manual'] })
202
+ renderPage('/billing?interval=yearly&channel=manual')
203
+ await waitFor(() => expect(screen.getByText('¥99.00')).toBeInTheDocument())
204
+ fireEvent.click(screen.getByRole('button', { name: '升级' }))
205
+ await waitFor(() =>
206
+ expect(apiMocks.post).toHaveBeenCalledWith('/api/billing/checkout', {
207
+ plan_code: 'pro', interval: 'yearly', channel: 'manual',
208
+ }),
209
+ )
210
+ })
211
+
212
+ it('取消 pending 订单 → POST orders/{id}/cancel;B8:确认框周期用中文、术语"待处理"', async () => {
213
+ renderPage()
214
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
215
+ expect(confirmSpy).toHaveBeenCalledWith(
216
+ expect.objectContaining({ description: '取消 pro(按年)的待处理订单?' }),
217
+ )
218
+ await waitFor(() =>
219
+ expect(apiMocks.post).toHaveBeenCalledWith('/api/billing/orders/o-1/cancel'),
220
+ )
221
+ await waitFor(() => expect(toastMocks.success).toHaveBeenCalled())
222
+ })
223
+
224
+ it('B8:订单卡文案与徽章术语统一("待处理"),不再"待支付"', () => {
225
+ renderPage()
226
+ expect(screen.getByText(/待处理 1 笔/)).toBeInTheDocument()
227
+ expect(screen.queryByText(/待支付/)).not.toBeInTheDocument()
228
+ })
229
+ })
230
+
231
+ describe('BillingPage B1/G4:manual 渠道接线(渠道表动态生成)', () => {
232
+ it('/config 只注册 manual → 渠道表仅含 manual,未注册渠道不出现;升级可用且 POST channel=manual', async () => {
233
+ clientGet.mockResolvedValue({ billing_channels: ['manual'] })
234
+ renderPage()
235
+
236
+
237
+
238
+ await screen.findByText('升级立即生效;下单后请联系管理员完成线下转账,订单在管理员核销后生效。')
239
+ expect(screen.queryByText(/Stripe 结账完成后自动跳回/)).not.toBeInTheDocument()
240
+
241
+ const trigger = screen.getByRole('combobox', { name: '支付渠道' })
242
+ expect(trigger).toHaveTextContent('线下转账(管理员核销)')
243
+ fireEvent.click(trigger)
244
+ expect(await screen.findByRole('option', { name: '线下转账(管理员核销)' })).toBeInTheDocument()
245
+ expect(screen.queryByRole('option', { name: 'Stripe(银行卡)' })).not.toBeInTheDocument()
246
+ expect(screen.queryByRole('option', { name: '支付宝' })).not.toBeInTheDocument()
247
+ expect(screen.queryByRole('option', { name: '微信支付' })).not.toBeInTheDocument()
248
+
249
+ const upgrade = screen.getByRole('button', { name: '升级' })
250
+ expect(upgrade).toBeEnabled()
251
+ fireEvent.click(upgrade)
252
+ await waitFor(() =>
253
+ expect(apiMocks.post).toHaveBeenCalledWith('/api/billing/checkout', {
254
+ plan_code: 'pro', interval: 'monthly', channel: 'manual',
255
+ }),
256
+ )
257
+ })
258
+
259
+ it('未知渠道码回退为码本身展示(自定义渠道可选)', async () => {
260
+ clientGet.mockResolvedValue({ billing_channels: ['manual', 'paypal'] })
261
+ renderPage()
262
+ fireEvent.click(await screen.findByRole('combobox', { name: '支付渠道' }))
263
+ expect(await screen.findByRole('option', { name: '线下转账(管理员核销)' })).toBeInTheDocument()
264
+ expect(screen.getByRole('option', { name: 'paypal' })).toBeInTheDocument()
265
+ })
266
+
267
+ it('manual 空 InfoURL(checkout 201 但无 action_url)→ 渲染线下支付指引,不按错误处理', async () => {
268
+ clientGet.mockResolvedValue({ billing_channels: ['manual'] })
269
+ apiMocks.post.mockResolvedValue({ action_url: '' })
270
+ renderPage()
271
+
272
+
273
+ await screen.findByText('升级立即生效;下单后请联系管理员完成线下转账,订单在管理员核销后生效。')
274
+ fireEvent.click(screen.getByRole('button', { name: '升级' }))
275
+ const note = await screen.findByRole('note')
276
+ expect(note).toHaveTextContent('已创建 Pro 订单(待处理):下单后请联系管理员完成支付,订单在管理员核销后生效。')
277
+ expect(toastMocks.success).toHaveBeenCalledWith('订单已创建,请联系管理员完成支付')
278
+ expect(toastMocks.error).not.toHaveBeenCalled()
279
+ // manual 有 InfoURL 时 action_url 非空 → 正常跳转指引页(主链路测试已覆盖跳转分支)
280
+ })
281
+
282
+ it('/config 报零渠道 → 升级禁用 + 如实说明,不再"请切换支付渠道"式自相矛盾', async () => {
283
+ clientGet.mockResolvedValue({ billing_channels: [] })
284
+ renderPage()
285
+
286
+ const upgrade = await screen.findByRole('button', { name: '升级' })
287
+ await waitFor(() => expect(upgrade).toBeDisabled())
288
+ expect(screen.getByText('暂不可升级:站点未配置任何支付渠道,请联系管理员。')).toBeInTheDocument()
289
+ expect(screen.queryByText(/请切换支付渠道/)).not.toBeInTheDocument()
290
+ })
291
+
292
+ it('/config 失败 → 不放大为整页错误:渠道表回退已知全集不设限', async () => {
293
+ clientGet.mockRejectedValue(new Error('config down'))
294
+ renderPage()
295
+
296
+ expect(await screen.findByRole('button', { name: '升级' })).toBeEnabled()
297
+ fireEvent.click(screen.getByRole('combobox', { name: '支付渠道' }))
298
+ expect(await screen.findByRole('option', { name: '线下转账(管理员核销)' })).toBeInTheDocument()
299
+ expect(screen.getByRole('option', { name: 'Stripe(银行卡)' })).toBeInTheDocument()
300
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
301
+ })
302
+ })
@@ -0,0 +1,134 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import type { ReactNode } from 'react'
3
+ import { render, screen, waitFor } from '@testing-library/react'
4
+ import { MemoryRouter, Routes, Route } from 'react-router'
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
6
+ import { BillingSuccessPage } from '../BillingSuccessPage'
7
+
8
+
9
+
10
+ let queryData: unknown = null
11
+ let queryStamp = 0
12
+
13
+ let cacheData: unknown = null
14
+ vi.mock('@tanstack/react-query', () => ({
15
+ useQuery: () => ({ data: queryData, dataUpdatedAt: queryStamp, isPending: false, error: null }),
16
+ useQueryClient: () => ({ getQueryData: () => cacheData }),
17
+ }))
18
+
19
+ vi.mock('../../../../../hooks/src/hooks/useWorkspace', () => ({
20
+ useWorkspace: () => ({
21
+ current: { id: 'ws-1', name: 'Acme', slug: 'acme', plan_code: 'free', role: 'owner' },
22
+ switchTo: vi.fn(),
23
+ clear: vi.fn(),
24
+ }),
25
+ }))
26
+
27
+ const apiMocks = vi.hoisted(() => ({
28
+ get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(),
29
+ }))
30
+ vi.mock('../../../../../hooks/src/hooks/useApi', () => ({ useApi: () => apiMocks }))
31
+
32
+ const toastMocks = vi.hoisted(() => ({
33
+ success: vi.fn(), error: vi.fn(), info: vi.fn(),
34
+ }))
35
+ vi.mock('../../../components/toast', () => ({ toast: toastMocks }))
36
+
37
+ const PAID_SUB = {
38
+ plan_code: 'pro',
39
+ orders: [
40
+ { id: 'o-paid', plan_code: 'pro', interval: 'monthly', amount_cents: 990, currency: 'usd', status: 'paid', created_at: '2026-10-05T00:00:00Z' },
41
+ ],
42
+ }
43
+
44
+ function renderPage() {
45
+ return render(
46
+ <MemoryRouter initialEntries={['/billing/success']}>
47
+ <Routes>
48
+ <Route path="/billing/success" element={<BillingSuccessPage />} />
49
+ <Route path="/settings/workspace/billing" element={<div>billing-page</div>} />
50
+ </Routes>
51
+ </MemoryRouter>,
52
+ )
53
+ }
54
+
55
+ function rerenderPage(rerender: (node: ReactNode) => void) {
56
+ rerender(
57
+ <MemoryRouter initialEntries={['/billing/success']}>
58
+ <Routes>
59
+ <Route path="/billing/success" element={<BillingSuccessPage />} />
60
+ <Route path="/settings/workspace/billing" element={<div>billing-page</div>} />
61
+ </Routes>
62
+ </MemoryRouter>,
63
+ )
64
+ }
65
+
66
+ beforeEach(() => {
67
+ vi.clearAllMocks()
68
+ queryData = null
69
+ queryStamp = 0
70
+ cacheData = null
71
+ })
72
+
73
+ describe('BillingSuccessPage', () => {
74
+ it('轮询期间渲染骨架与提示', () => {
75
+ queryData = { plan_code: 'free', orders: [] }
76
+ queryStamp = 1
77
+ renderPage()
78
+ expect(screen.getByText('正在确认支付结果…')).toBeInTheDocument()
79
+ expect(screen.getByText(/请勿关闭页面/)).toBeInTheDocument()
80
+ })
81
+
82
+ it('出现已支付订单 → toast 成功并跳转计费页', async () => {
83
+ queryData = { plan_code: 'free', orders: [] }
84
+ queryStamp = 1
85
+ const { rerender } = renderPage()
86
+
87
+
88
+ queryData = PAID_SUB
89
+ queryStamp = 2
90
+ rerenderPage(rerender)
91
+
92
+ await waitFor(() => expect(toastMocks.success).toHaveBeenCalledWith('支付成功,套餐已生效'))
93
+ await waitFor(() => expect(screen.getByText('billing-page')).toBeInTheDocument())
94
+ })
95
+
96
+ it('套餐变化(无新订单)同样判定成功', async () => {
97
+ queryData = { plan_code: 'free', orders: [] }
98
+ queryStamp = 1
99
+ const { rerender } = renderPage()
100
+ queryData = { plan_code: 'pro', orders: [] }
101
+ queryStamp = 2
102
+ rerenderPage(rerender)
103
+ await waitFor(() => expect(toastMocks.success).toHaveBeenCalled())
104
+ })
105
+
106
+ it('P1-4:数据无变化轮询(data 引用不变、dataUpdatedAt 前进)→ 达到上限后超时兜底回跳', async () => {
107
+ const unchanged = { plan_code: 'free', orders: [] }
108
+ queryData = unchanged
109
+ queryStamp = 1
110
+ const { rerender } = renderPage()
111
+
112
+
113
+
114
+ for (let i = 2; i <= 14; i++) {
115
+ queryStamp = i
116
+ rerenderPage(rerender)
117
+ }
118
+ await waitFor(() => expect(toastMocks.info).toHaveBeenCalledWith('支付结果确认超时,可稍后在计费页查看'))
119
+ await waitFor(() => expect(screen.getByText('billing-page')).toBeInTheDocument())
120
+ expect(toastMocks.success).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('P1-4:基线来自 query 缓存(支付完成早于首次轮询)→ 首询即判定成功', async () => {
124
+
125
+ cacheData = { plan_code: 'free', orders: [] }
126
+
127
+ queryData = PAID_SUB
128
+ queryStamp = 1
129
+ renderPage()
130
+
131
+ await waitFor(() => expect(toastMocks.success).toHaveBeenCalledWith('支付成功,套餐已生效'))
132
+ await waitFor(() => expect(screen.getByText('billing-page')).toBeInTheDocument())
133
+ })
134
+ })