@allbluecn/admin-app 0.2.3 → 0.2.5

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.
@@ -0,0 +1,182 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
19
+
20
+ vi.mock('@tanstack/react-start', () => ({
21
+ createServerFn: (_config: unknown) => {
22
+ const api: any = {
23
+ _validator: undefined,
24
+ validator(schema: any) {
25
+ this._validator = schema
26
+ return this
27
+ },
28
+ handler(fn: Function) {
29
+ const wrapped = async (args: any) => {
30
+ let data = args?.data
31
+ if (this._validator && data !== undefined) {
32
+ data = this._validator.parse ? this._validator.parse(data) : data
33
+ }
34
+ return fn({ data })
35
+ }
36
+ return wrapped
37
+ },
38
+ }
39
+ return api
40
+ },
41
+ }))
42
+
43
+ vi.mock('@tanstack/react-start/server', () => ({
44
+ getRequest: () => ({}),
45
+ }))
46
+
47
+ vi.mock('@allbluecn/database', () => ({
48
+ prisma: {
49
+ subscription: {
50
+ findMany: vi.fn(),
51
+ count: vi.fn(),
52
+ findUnique: vi.fn(),
53
+ },
54
+ invoice: {
55
+ findMany: vi.fn(),
56
+ count: vi.fn(),
57
+ },
58
+ },
59
+ }))
60
+
61
+ vi.mock('@/server/auth-helpers', () => ({
62
+ requireAdmin: vi.fn().mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } }),
63
+ }))
64
+
65
+ import { prisma } from '@allbluecn/database'
66
+ import { requireAdmin } from '@/server/auth-helpers'
67
+ import {
68
+ listAllSubscriptionsFn,
69
+ getSubscriptionDetailFn,
70
+ listAllInvoicesFn,
71
+ } from '../serverFns/admin-billing'
72
+
73
+ const subscription = prisma.subscription as unknown as {
74
+ findMany: ReturnType<typeof vi.fn>
75
+ count: ReturnType<typeof vi.fn>
76
+ findUnique: ReturnType<typeof vi.fn>
77
+ }
78
+ const invoice = prisma.invoice as unknown as {
79
+ findMany: ReturnType<typeof vi.fn>
80
+ count: ReturnType<typeof vi.fn>
81
+ }
82
+ const requireAdminMock = requireAdmin as unknown as ReturnType<typeof vi.fn>
83
+
84
+ beforeEach(() => {
85
+ vi.clearAllMocks()
86
+ requireAdminMock.mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } })
87
+ subscription.findMany.mockResolvedValue([])
88
+ subscription.count.mockResolvedValue(0)
89
+ invoice.findMany.mockResolvedValue([])
90
+ invoice.count.mockResolvedValue(0)
91
+ })
92
+
93
+ describe('listAllSubscriptionsFn', () => {
94
+ it('非管理员拒绝:requireAdmin 抛错时零 prisma 调用', async () => {
95
+ requireAdminMock.mockRejectedValue(new Error('not admin'))
96
+
97
+ await expect(listAllSubscriptionsFn({ data: {} })).rejects.toThrow('not admin')
98
+ expect(subscription.findMany).not.toHaveBeenCalled()
99
+ expect(subscription.count).not.toHaveBeenCalled()
100
+ })
101
+
102
+ it('默认分页:page=1 pageSize=20 时 skip=0 take=20', async () => {
103
+ await listAllSubscriptionsFn({ data: {} })
104
+
105
+ expect(subscription.findMany).toHaveBeenCalledWith({
106
+ where: {},
107
+ include: { plan: true, workspace: true },
108
+ orderBy: { createdAt: 'desc' },
109
+ skip: 0,
110
+ take: 20,
111
+ })
112
+ })
113
+
114
+ it('search 按工作空间名模糊匹配(管理员全局检索入口)', async () => {
115
+ await listAllSubscriptionsFn({ data: { search: 'acme' } })
116
+
117
+ const arg = subscription.findMany.mock.calls[0][0]
118
+ expect(arg.where.OR).toEqual([{ workspace: { name: { contains: 'acme' } } }])
119
+ expect(subscription.count).toHaveBeenCalledWith({
120
+ where: { OR: [{ workspace: { name: { contains: 'acme' } } }] },
121
+ })
122
+ })
123
+
124
+ it('plan 与 status 组合过滤同时生效', async () => {
125
+ await listAllSubscriptionsFn({ data: { plan: 'pro', status: 'active' } })
126
+
127
+ expect(subscription.findMany.mock.calls[0][0].where).toEqual({
128
+ planId: 'pro',
129
+ status: 'active',
130
+ })
131
+ })
132
+
133
+ it('分页偏移正确计算(page=3 pageSize=10 → skip=20)', async () => {
134
+ await listAllSubscriptionsFn({ data: { page: 3, pageSize: 10 } })
135
+
136
+ const arg = subscription.findMany.mock.calls[0][0]
137
+ expect(arg.skip).toBe(20)
138
+ expect(arg.take).toBe(10)
139
+ })
140
+ })
141
+
142
+ describe('getSubscriptionDetailFn', () => {
143
+ it('按 subscriptionId 查详情并附带发票列表', async () => {
144
+ subscription.findUnique.mockResolvedValue({ id: 'sub-1' })
145
+
146
+ const res = await getSubscriptionDetailFn({ data: { subscriptionId: 'sub-1' } })
147
+
148
+ expect(subscription.findUnique).toHaveBeenCalledWith({
149
+ where: { id: 'sub-1' },
150
+ include: {
151
+ plan: true,
152
+ workspace: true,
153
+ invoices: { orderBy: { createdAt: 'desc' } },
154
+ },
155
+ })
156
+ expect(res).toEqual({ id: 'sub-1' })
157
+ })
158
+
159
+ it('非管理员拒绝:不触达 subscription 查询', async () => {
160
+ requireAdminMock.mockRejectedValue(new Error('not admin'))
161
+
162
+ await expect(
163
+ getSubscriptionDetailFn({ data: { subscriptionId: 'sub-1' } }),
164
+ ).rejects.toThrow('not admin')
165
+ expect(subscription.findUnique).not.toHaveBeenCalled()
166
+ })
167
+ })
168
+
169
+ describe('listAllInvoicesFn', () => {
170
+ it('按 createdAt 降序分页并附带订阅归属信息', async () => {
171
+ await listAllInvoicesFn({ data: { page: 2, pageSize: 5 } })
172
+
173
+ expect(invoice.findMany).toHaveBeenCalledWith({
174
+ orderBy: { createdAt: 'desc' },
175
+ include: { subscription: { include: { workspace: true, plan: true } } },
176
+ skip: 5,
177
+ take: 5,
178
+ })
179
+ // 发票总数不受分页影响
180
+ expect(invoice.count).toHaveBeenCalledWith()
181
+ })
182
+ })
@@ -0,0 +1,136 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
19
+
20
+ vi.mock('@tanstack/react-start', () => ({
21
+ createServerFn: (_config: unknown) => {
22
+ const api: any = {
23
+ _validator: undefined,
24
+ validator(schema: any) {
25
+ this._validator = schema
26
+ return this
27
+ },
28
+ handler(fn: Function) {
29
+ const wrapped = async (args: any) => {
30
+ let data = args?.data
31
+ if (this._validator && data !== undefined) {
32
+ data = this._validator.parse ? this._validator.parse(data) : data
33
+ }
34
+ return fn({ data })
35
+ }
36
+ return wrapped
37
+ },
38
+ }
39
+ return api
40
+ },
41
+ }))
42
+
43
+ vi.mock('@tanstack/react-start/server', () => ({
44
+ getRequest: () => ({}),
45
+ }))
46
+
47
+ vi.mock('@allbluecn/database', () => ({
48
+ prisma: {
49
+ complianceExport: {
50
+ findMany: vi.fn(),
51
+ count: vi.fn(),
52
+ },
53
+ },
54
+ }))
55
+
56
+ vi.mock('@/server/auth-helpers', () => ({
57
+ requireAdmin: vi.fn().mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } }),
58
+ }))
59
+
60
+ vi.mock('@/server/entitlements', () => ({
61
+ requireFeature: vi.fn().mockResolvedValue(undefined),
62
+ }))
63
+
64
+ const services = vi.hoisted(() => new Map<string, unknown>())
65
+
66
+ vi.mock('@/server/pro-services', () => ({
67
+ getProService: (key: string) => services.get(key) ?? null,
68
+ }))
69
+
70
+ import { prisma } from '@allbluecn/database'
71
+ import { requireAdmin } from '@/server/auth-helpers'
72
+ import { requireFeature } from '@/server/entitlements'
73
+ import { listComplianceExportsFn } from '../serverFns/compliance'
74
+
75
+ const complianceExport = prisma.complianceExport as unknown as {
76
+ findMany: ReturnType<typeof vi.fn>
77
+ count: ReturnType<typeof vi.fn>
78
+ }
79
+
80
+ describe('listComplianceExportsFn(admin 合规导出任务列表)', () => {
81
+ beforeEach(() => {
82
+ vi.clearAllMocks()
83
+ services.clear()
84
+ })
85
+
86
+ it('Pro 服务注册时委派给 Pro 实现', async () => {
87
+ const listJobs = vi.fn().mockResolvedValue({ items: ['pro-job'], total: 1 })
88
+ services.set('compliance', { listJobs })
89
+
90
+ const result = await listComplianceExportsFn({ data: { page: 1, pageSize: 20 } })
91
+
92
+ expect(listJobs).toHaveBeenCalledWith({ page: 1, pageSize: 20 })
93
+ expect(result).toEqual({ items: ['pro-job'], total: 1 })
94
+ // Pro 路径不应触达 prisma
95
+ expect(complianceExport.findMany).not.toHaveBeenCalled()
96
+ })
97
+
98
+ it('Pro 服务缺省时走 prisma 分页查询,skip/take 按页计算', async () => {
99
+ const items = [{ id: 'e-1' }]
100
+ complianceExport.findMany.mockResolvedValue(items)
101
+ complianceExport.count.mockResolvedValue(42)
102
+
103
+ const result = await listComplianceExportsFn({ data: { page: 3, pageSize: 10 } })
104
+
105
+ expect(complianceExport.findMany).toHaveBeenCalledWith({
106
+ orderBy: { createdAt: 'desc' },
107
+ skip: 20,
108
+ take: 10,
109
+ })
110
+ expect(complianceExport.count).toHaveBeenCalledWith()
111
+ expect(result).toEqual({ items, total: 42 })
112
+ })
113
+
114
+ it('未传分页参数时使用默认值(page=1, pageSize=20)', async () => {
115
+ complianceExport.findMany.mockResolvedValue([])
116
+ complianceExport.count.mockResolvedValue(0)
117
+
118
+ await listComplianceExportsFn({ data: {} })
119
+
120
+ expect(complianceExport.findMany).toHaveBeenCalledWith({
121
+ orderBy: { createdAt: 'desc' },
122
+ skip: 0,
123
+ take: 20,
124
+ })
125
+ })
126
+
127
+ it('查询前强制管理员身份与合规功能授权', async () => {
128
+ complianceExport.findMany.mockResolvedValue([])
129
+ complianceExport.count.mockResolvedValue(0)
130
+
131
+ await listComplianceExportsFn({ data: { page: 1, pageSize: 20 } })
132
+
133
+ expect(requireAdmin).toHaveBeenCalledTimes(1)
134
+ expect(requireFeature).toHaveBeenCalledWith({}, 'compliance')
135
+ })
136
+ })
@@ -0,0 +1,311 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
19
+
20
+ vi.mock('@tanstack/react-start', () => ({
21
+ createServerFn: (_config: unknown) => {
22
+ const api: any = {
23
+ _validator: undefined,
24
+ validator(schema: any) {
25
+ this._validator = schema
26
+ return this
27
+ },
28
+ handler(fn: Function) {
29
+ const wrapped = async (args: any) => {
30
+ let data = args?.data
31
+ if (this._validator && data !== undefined) {
32
+ data = this._validator.parse ? this._validator.parse(data) : data
33
+ }
34
+ return fn({ data })
35
+ }
36
+ return wrapped
37
+ },
38
+ }
39
+ return api
40
+ },
41
+ }))
42
+
43
+ vi.mock('@tanstack/react-start/server', () => ({
44
+ getRequest: () => ({}),
45
+ }))
46
+
47
+ vi.mock('@allbluecn/database', () => ({
48
+ prisma: {
49
+ invitationCode: {
50
+ findMany: vi.fn(),
51
+ count: vi.fn(),
52
+ findUnique: vi.fn(),
53
+ create: vi.fn(),
54
+ update: vi.fn(),
55
+ delete: vi.fn(),
56
+ },
57
+ user: {
58
+ findMany: vi.fn(),
59
+ },
60
+ systemSetting: {
61
+ findUnique: vi.fn(),
62
+ upsert: vi.fn(),
63
+ },
64
+ },
65
+ }))
66
+
67
+ vi.mock('@/server/auth-helpers', () => ({
68
+ requireAdmin: vi
69
+ .fn()
70
+ .mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } }),
71
+ }))
72
+
73
+ import { prisma } from '@allbluecn/database'
74
+ import {
75
+ listInvitationCodesFn,
76
+ createInvitationCodeFn,
77
+ toggleInvitationCodeFn,
78
+ deleteInvitationCodeFn,
79
+ getRegistrationModeFn,
80
+ setRegistrationModeFn,
81
+ } from '../serverFns/invitations'
82
+
83
+ const ic = prisma.invitationCode as unknown as {
84
+ findMany: ReturnType<typeof vi.fn>
85
+ count: ReturnType<typeof vi.fn>
86
+ findUnique: ReturnType<typeof vi.fn>
87
+ create: ReturnType<typeof vi.fn>
88
+ update: ReturnType<typeof vi.fn>
89
+ delete: ReturnType<typeof vi.fn>
90
+ }
91
+
92
+ const user = prisma.user as unknown as { findMany: ReturnType<typeof vi.fn> }
93
+ const systemSetting = prisma.systemSetting as unknown as {
94
+ findUnique: ReturnType<typeof vi.fn>
95
+ upsert: ReturnType<typeof vi.fn>
96
+ }
97
+
98
+ describe('invitations serverFn(admin 邀请码 CRUD 与注册模式)', () => {
99
+ beforeEach(() => {
100
+ vi.clearAllMocks()
101
+ })
102
+
103
+ it('listInvitationCodesFn:分页查询 disabledAt=null 并批量解析创建人', async () => {
104
+ const codes = [
105
+ { id: 'c1', code: 'ABC1', maxUses: 1, usedCount: 0, expiresAt: new Date(), createdById: 'u1', disabledAt: null, createdAt: new Date(), uses: [] },
106
+ { id: 'c2', code: 'DEF2', maxUses: 5, usedCount: 2, expiresAt: new Date(), createdById: null, disabledAt: null, createdAt: new Date(), uses: [] },
107
+ ]
108
+ ic.findMany.mockResolvedValue(codes)
109
+ ic.count.mockResolvedValue(42)
110
+ user.findMany.mockResolvedValue([{ id: 'u1', username: 'alice' }])
111
+
112
+ const result = await listInvitationCodesFn({ data: { page: 2, pageSize: 20 } })
113
+
114
+ expect(ic.findMany).toHaveBeenCalledWith(
115
+ expect.objectContaining({ skip: 20, take: 20, where: { disabledAt: null } }),
116
+ )
117
+ expect(ic.count).toHaveBeenCalledWith({ where: { disabledAt: null } })
118
+ expect(user.findMany).toHaveBeenCalledWith({
119
+ where: { id: { in: ['u1'] } },
120
+ select: { id: true, username: true },
121
+ })
122
+ expect(result).toMatchObject({
123
+ total: 42,
124
+ page: 2,
125
+ pageSize: 20,
126
+ codes: [
127
+ { code: 'ABC1', creator: 'alice' },
128
+ { code: 'DEF2', creator: null },
129
+ ],
130
+ })
131
+ })
132
+
133
+ it('listInvitationCodesFn:无创建人时不发起用户查询', async () => {
134
+ ic.findMany.mockResolvedValue([{ id: 'c1', createdById: null, uses: [] }])
135
+ ic.count.mockResolvedValue(1)
136
+
137
+ await listInvitationCodesFn({ data: { page: 1, pageSize: 10 } })
138
+
139
+ expect(user.findMany).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it('createInvitationCodeFn:自定义 code 归一化为大写并写入 createdById', async () => {
143
+ ic.create.mockImplementation(({ data }: { data: { code: string } }) =>
144
+ Promise.resolve({ id: 'c-new', code: data.code }),
145
+ )
146
+
147
+ const result = await createInvitationCodeFn({ data: { code: ' ab12cd34 ' } })
148
+
149
+ expect(ic.create).toHaveBeenCalledWith({
150
+ data: expect.objectContaining({
151
+ code: 'AB12CD34',
152
+ maxUses: 1,
153
+ createdById: 'admin-1',
154
+ }),
155
+ })
156
+ expect(result.code).toBe('AB12CD34')
157
+ })
158
+
159
+ it('createInvitationCodeFn:自定义 code 过短 → 拒绝', async () => {
160
+ await expect(createInvitationCodeFn({ data: { code: 'abc000' } })).rejects.toThrow(
161
+ '邀请码需为 8-12 位字母或数字',
162
+ )
163
+ expect(ic.create).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('createInvitationCodeFn:自定义 code 含下划线短横 → 拒绝', async () => {
167
+ await expect(createInvitationCodeFn({ data: { code: 'ab12_cd34' } })).rejects.toThrow(
168
+ '邀请码需为 8-12 位字母或数字',
169
+ )
170
+ })
171
+
172
+ it('createInvitationCodeFn:纯字母 code(缺数字)→ 拒绝', async () => {
173
+ await expect(createInvitationCodeFn({ data: { code: 'abcdefgh' } })).rejects.toThrow(
174
+ '邀请码需同时包含字母和数字',
175
+ )
176
+ })
177
+
178
+ it('createInvitationCodeFn:纯数字 code(缺字母)→ 拒绝', async () => {
179
+ await expect(createInvitationCodeFn({ data: { code: '12345678' } })).rejects.toThrow(
180
+ '邀请码需同时包含字母和数字',
181
+ )
182
+ })
183
+
184
+ it('createInvitationCodeFn:未传 code 时自动生成满足 /^[A-F0-9]{8}$/', async () => {
185
+ ic.create.mockImplementation(({ data }: { data: { code: string } }) =>
186
+ Promise.resolve({ id: 'c-new', code: data.code }),
187
+ )
188
+
189
+ const result = await createInvitationCodeFn({ data: {} })
190
+
191
+ expect(result.code).toMatch(/^[A-F0-9]{8}$/)
192
+ })
193
+
194
+ it('toggleInvitationCodeFn:已启用(disabledAt=null)→ 写入当前时间', async () => {
195
+ ic.findUnique.mockResolvedValue({ id: 'c1', disabledAt: null })
196
+ const captures: Array<{ where: { id: string }; data: { disabledAt: Date | null } }> = []
197
+ ic.update.mockImplementation((arg: { where: { id: string }; data: { disabledAt: Date | null } }) => {
198
+ captures.push(arg)
199
+ return Promise.resolve({ id: arg.where.id, disabledAt: arg.data.disabledAt })
200
+ })
201
+
202
+ const before = Date.now()
203
+ await toggleInvitationCodeFn({ data: { id: 'c1' } })
204
+ const after = Date.now()
205
+
206
+ expect(captures).toHaveLength(1)
207
+ expect(captures[0].where).toEqual({ id: 'c1' })
208
+ const written = captures[0].data.disabledAt
209
+ expect(written).toBeInstanceOf(Date)
210
+ if (written instanceof Date) {
211
+ expect(written.getTime()).toBeGreaterThanOrEqual(before)
212
+ expect(written.getTime()).toBeLessThanOrEqual(after)
213
+ }
214
+ })
215
+
216
+ it('toggleInvitationCodeFn:已禁用 → 置回 null(重新启用)', async () => {
217
+ ic.findUnique.mockResolvedValue({ id: 'c1', disabledAt: new Date('2026-01-01') })
218
+ ic.update.mockImplementation(({ data }: { data: { disabledAt: Date | null } }) =>
219
+ Promise.resolve({ id: 'c1', disabledAt: data.disabledAt }),
220
+ )
221
+
222
+ const result = await toggleInvitationCodeFn({ data: { id: 'c1' } })
223
+
224
+ expect(result.disabledAt).toBeNull()
225
+ })
226
+
227
+ it('toggleInvitationCodeFn:不存在的 id 抛「邀请码不存在」', async () => {
228
+ ic.findUnique.mockResolvedValue(null)
229
+
230
+ await expect(toggleInvitationCodeFn({ data: { id: 'missing' } })).rejects.toThrow(
231
+ '邀请码不存在',
232
+ )
233
+ })
234
+
235
+ it('deleteInvitationCodeFn:usedCount > 0 拒绝删除', async () => {
236
+ ic.findUnique.mockResolvedValue({ id: 'c1', usedCount: 3 })
237
+
238
+ await expect(deleteInvitationCodeFn({ data: { id: 'c1' } })).rejects.toThrow(
239
+ '已有使用记录,请改用禁用',
240
+ )
241
+ expect(ic.delete).not.toHaveBeenCalled()
242
+ })
243
+
244
+ it('deleteInvitationCodeFn:usedCount === 0 删除成功', async () => {
245
+ ic.findUnique.mockResolvedValue({ id: 'c1', usedCount: 0 })
246
+ ic.delete.mockResolvedValue({ id: 'c1' })
247
+
248
+ const result = await deleteInvitationCodeFn({ data: { id: 'c1' } })
249
+
250
+ expect(ic.delete).toHaveBeenCalledWith({ where: { id: 'c1' } })
251
+ expect(result).toEqual({ deleted: true })
252
+ })
253
+
254
+ it('deleteInvitationCodeFn:不存在的 id 抛「邀请码不存在」', async () => {
255
+ ic.findUnique.mockResolvedValue(null)
256
+
257
+ await expect(deleteInvitationCodeFn({ data: { id: 'missing' } })).rejects.toThrow(
258
+ '邀请码不存在',
259
+ )
260
+ })
261
+
262
+ it('getRegistrationModeFn:无行时默认 invite', async () => {
263
+ systemSetting.findUnique.mockResolvedValue(null)
264
+
265
+ await expect(getRegistrationModeFn({ data: {} })).resolves.toEqual({ mode: 'invite' })
266
+ })
267
+
268
+ it('getRegistrationModeFn:value=open 返回 open', async () => {
269
+ systemSetting.findUnique.mockResolvedValue({ key: 'auth.registrationMode', value: 'open' })
270
+
271
+ await expect(getRegistrationModeFn({ data: {} })).resolves.toEqual({ mode: 'open' })
272
+ })
273
+
274
+ it('setRegistrationModeFn:写入后 upsert create 分支带 category=auth', async () => {
275
+ systemSetting.upsert.mockResolvedValue({ id: 's1' })
276
+
277
+ const result = await setRegistrationModeFn({ data: { mode: 'open' } })
278
+
279
+ expect(systemSetting.upsert).toHaveBeenCalledWith({
280
+ where: { key: 'auth.registrationMode' },
281
+ update: { value: 'open' },
282
+ create: expect.objectContaining({
283
+ key: 'auth.registrationMode',
284
+ value: 'open',
285
+ category: 'auth',
286
+ }),
287
+ })
288
+ expect(result).toEqual({ mode: 'open' })
289
+ })
290
+
291
+ it('setRegistrationModeFn + getRegistrationModeFn:写读一致', async () => {
292
+ let stored: string | null = null
293
+ systemSetting.upsert.mockImplementation(
294
+ ({ update }: { update: { value: string } }) => {
295
+ stored = update.value
296
+ return Promise.resolve({ id: 's1', value: stored })
297
+ },
298
+ )
299
+ systemSetting.findUnique.mockImplementation(() =>
300
+ stored === null
301
+ ? Promise.resolve(null)
302
+ : Promise.resolve({ key: 'auth.registrationMode', value: stored }),
303
+ )
304
+
305
+ await setRegistrationModeFn({ data: { mode: 'open' } })
306
+ await expect(getRegistrationModeFn({ data: {} })).resolves.toEqual({ mode: 'open' })
307
+
308
+ await setRegistrationModeFn({ data: { mode: 'invite' } })
309
+ await expect(getRegistrationModeFn({ data: {} })).resolves.toEqual({ mode: 'invite' })
310
+ })
311
+ })