@allbluecn/web-app 0.4.17 → 0.4.18

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 (67) hide show
  1. package/package.json +10 -10
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
  6. package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
  7. package/src/components/notifications/notification-list.tsx +232 -0
  8. package/src/components/notifications/notification-preferences.tsx +156 -0
  9. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  10. package/src/components/settings/ai/provider-grid.tsx +46 -25
  11. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  12. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  13. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  14. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  15. package/src/hooks/use-notifications.ts +119 -0
  16. package/src/lib/app-version.ts +4 -1
  17. package/src/lib/auth/auth.config.ts +17 -1
  18. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  19. package/src/lib/changelog/sdk-versions.ts +29 -19
  20. package/src/lib/collections/ai/index.ts +0 -1
  21. package/src/lib/collections/index.ts +0 -1
  22. package/src/plugins/.gen-manifest.json +13 -0
  23. package/src/plugins/modules.gen.ts +20 -0
  24. package/src/plugins/plugins.gen.ts +20 -0
  25. package/src/plugins/ui.gen.ts +10 -0
  26. package/src/routeTree.gen.ts +70 -0
  27. package/src/routes/_login/login/route.tsx +10 -1
  28. package/src/routes/_login/register/route.lazy.tsx +3 -2
  29. package/src/routes/_login/register/route.tsx +5 -0
  30. package/src/routes/_nav/inspiration/route.tsx +8 -0
  31. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  32. package/src/routes/_nav/notifications/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  37. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  38. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  39. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  40. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  41. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  43. package/src/routes/_nav/tags/index.tsx +8 -0
  44. package/src/routes/_nav/tags/route.tsx +8 -0
  45. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  46. package/src/routes/api/notifications/stream.ts +88 -0
  47. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  48. package/src/routes/invite/$token/route.tsx +16 -8
  49. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  50. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  51. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  52. package/src/server/notifications/event-bus.ts +16 -0
  53. package/src/server/notifications/notification-service.ts +86 -0
  54. package/src/server/resources.ts +10 -2
  55. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  56. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  57. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  58. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  59. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  60. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  61. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  62. package/src/server/serverFns/billing.ts +23 -2
  63. package/src/server/serverFns/notifications.ts +155 -0
  64. package/src/server/serverFns/story/story-comments.ts +42 -0
  65. package/src/server/serverFns/team.ts +573 -53
  66. package/src/start.ts +23 -1
  67. package/vite.shared.ts +13 -0
@@ -39,7 +39,10 @@ vi.mock('@allbluecn/database', () => ({
39
39
  findMany: vi.fn(),
40
40
  update: vi.fn(),
41
41
  count: vi.fn(),
42
+ deleteMany: vi.fn(),
42
43
  },
44
+ teamJoinRequest: { updateMany: vi.fn(), findMany: vi.fn() },
45
+ $transaction: vi.fn(),
43
46
  },
44
47
  }))
45
48
 
@@ -60,84 +63,151 @@ vi.mock('@/server/email/sender', () => ({
60
63
  }))
61
64
 
62
65
  import { prisma } from '@allbluecn/database'
63
- import { createTeamInvitationFn } from '../team'
66
+ import {
67
+ createTeamInvitationFn,
68
+ refreshTeamInvitationLinkFn,
69
+ listTeamInvitationsFn,
70
+ clearExpiredInvitationsFn,
71
+ } from '../team'
64
72
 
65
- describe('createTeamInvitationFn 互斥校验', () => {
73
+ describe('refreshTeamInvitationLinkFn', () => {
66
74
  beforeEach(() => vi.clearAllMocks())
67
75
 
68
- it('邀请自己 → 拒绝', async () => {
69
- ;(prisma.user.findUnique as any).mockImplementation(async (opts: any) => {
70
- if (opts.where.id === 'owner1') return { id: 'owner1', email: 'a@x.com' }
71
- return null
76
+ it('生成新链接,删除旧链接', async () => {
77
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
78
+ const tx = {
79
+ teamInvitation: {
80
+ deleteMany: vi.fn().mockResolvedValue({}),
81
+ create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'abc123' }),
82
+ },
83
+ teamJoinRequest: {
84
+ updateMany: vi.fn().mockResolvedValue({}),
85
+ },
86
+ }
87
+ return fn(tx)
72
88
  })
73
- await expect(
74
- createTeamInvitationFn({ data: { email: 'a@x.com', seatType: 'member', channel: 'email' } }),
75
- ).rejects.toMatchObject({ message: expect.stringContaining('自己') })
89
+ const result = await refreshTeamInvitationLinkFn({ data: {} })
90
+ expect(result.success).toBe(true)
91
+ expect(result.invitationId).toBe('inv-new')
92
+ expect(result.token).toBeTruthy()
93
+ expect(result.token.length).toBeGreaterThan(0)
76
94
  })
77
95
 
78
- it('目标已在任何团队 → 拒绝', async () => {
79
- ;(prisma.user.findUnique as any).mockImplementation(async (opts: any) => {
80
- if (opts.where.id === 'owner1') return { id: 'owner1', email: 'a@x.com' }
81
- if (opts.where.email === 'b@x.com') return { id: 'u2', email: 'b@x.com' }
82
- return null
96
+ it('支持自定义有效期', async () => {
97
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
98
+ const tx = {
99
+ teamInvitation: {
100
+ deleteMany: vi.fn().mockResolvedValue({}),
101
+ create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'xyz' }),
102
+ },
103
+ teamJoinRequest: {
104
+ updateMany: vi.fn().mockResolvedValue({}),
105
+ },
106
+ }
107
+ return fn(tx)
83
108
  })
84
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
85
- ownerId: 'other',
86
- } as never)
87
- await expect(
88
- createTeamInvitationFn({ data: { email: 'b@x.com', seatType: 'member', channel: 'email' } }),
89
- ).rejects.toMatchObject({ message: expect.stringContaining('已加入') })
109
+ const result = await refreshTeamInvitationLinkFn({ data: { expiresInDays: 14 } })
110
+ expect(result.success).toBe(true)
90
111
  })
91
112
 
92
- it('邮箱已有 pending 邀请 → 拒绝', async () => {
93
- ;(prisma.user.findUnique as any).mockImplementation(async (opts: any) => {
94
- if (opts.where.id === 'owner1') return { id: 'owner1', email: 'a@x.com' }
95
- if (opts.where.email === 'b@x.com') return { id: 'u2', email: 'b@x.com' }
96
- return null
113
+ it('默认有效期 7 天', async () => {
114
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
115
+ const tx = {
116
+ teamInvitation: {
117
+ deleteMany: vi.fn().mockResolvedValue({}),
118
+ create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'tok' }),
119
+ },
120
+ teamJoinRequest: {
121
+ updateMany: vi.fn().mockResolvedValue({}),
122
+ },
123
+ }
124
+ return fn(tx)
97
125
  })
98
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
99
- vi.mocked(prisma.teamMembership.findFirst).mockResolvedValue(null)
100
- vi.mocked(prisma.teamInvitation.findFirst).mockResolvedValue({ id: 'inv1' } as never)
101
- await expect(
102
- createTeamInvitationFn({ data: { email: 'b@x.com', seatType: 'member', channel: 'email' } }),
103
- ).rejects.toMatchObject({ message: expect.stringContaining('待处理') })
126
+ await refreshTeamInvitationLinkFn({ data: {} })
127
+ const createCall = vi.mocked(prisma.$transaction).mock.calls[0][0]
128
+ expect(createCall).toBeDefined()
104
129
  })
130
+ })
105
131
 
106
- it('未注册邮箱 + 席位可用 → 创建邀请', async () => {
107
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
108
- vi.mocked(prisma.teamMembership.findFirst).mockResolvedValue(null)
109
- vi.mocked(prisma.teamInvitation.findFirst).mockResolvedValue(null)
110
- vi.mocked(prisma.teamInvitation.create).mockResolvedValue(
111
- { id: 'inv1', token: 't1' } as never,
112
- )
113
- const result = await createTeamInvitationFn({
114
- data: { email: 'new@x.com', seatType: 'guest', channel: 'link' },
115
- })
116
- expect(prisma.teamInvitation.create).toHaveBeenCalledWith(
132
+ describe('listTeamInvitationsFn', () => {
133
+ beforeEach(() => vi.clearAllMocks())
134
+
135
+ it('返回所有邀请(含已过期链接)', async () => {
136
+ vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 0 } as never)
137
+ vi.mocked(prisma.teamInvitation.findMany).mockResolvedValue([
138
+ {
139
+ id: 'inv2',
140
+ ownerId: 'owner1',
141
+ channel: 'email',
142
+ status: 'pending',
143
+ token: 'tok2',
144
+ email: 'b@x.com',
145
+ expiresAt: new Date(Date.now() + 86400000),
146
+ createdAt: new Date(Date.now() + 1000),
147
+ owner: { id: 'owner1', username: 'A', email: 'a@x.com' },
148
+ },
149
+ {
150
+ id: 'inv1',
151
+ ownerId: 'owner1',
152
+ channel: 'link',
153
+ status: 'expired',
154
+ token: 'old',
155
+ expiresAt: new Date(Date.now() - 86400000),
156
+ createdAt: new Date(Date.now() - 86400000),
157
+ owner: { id: 'owner1', username: 'A', email: 'a@x.com' },
158
+ },
159
+ ] as never)
160
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 0 } as never)
161
+ vi.mocked(prisma.teamJoinRequest.findMany).mockResolvedValue([] as never)
162
+ const result = await listTeamInvitationsFn()
163
+ expect(result.invitations).toHaveLength(2)
164
+ expect(result.invitations[0].channel).toBe('email')
165
+ expect(result.invitations[1].channel).toBe('link')
166
+ expect(result.joinRequests).toEqual([])
167
+ })
168
+
169
+ it('过期 pending/pending_approval 邀请自动标记为 expired', async () => {
170
+ vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 1 } as never)
171
+ vi.mocked(prisma.teamInvitation.findMany).mockResolvedValue([])
172
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 0 } as never)
173
+ vi.mocked(prisma.teamJoinRequest.findMany).mockResolvedValue([])
174
+ await listTeamInvitationsFn()
175
+ expect(prisma.teamInvitation.updateMany).toHaveBeenCalledWith(
117
176
  expect.objectContaining({
118
- data: expect.objectContaining({
119
- email: 'new@x.com',
120
- seatType: 'guest',
121
- token: expect.any(String),
122
- expiresAt: expect.any(Date),
177
+ where: expect.objectContaining({
178
+ status: { in: ['pending', 'pending_approval'] },
123
179
  }),
124
180
  }),
125
181
  )
126
- expect(result.success).toBe(true)
127
182
  })
183
+ })
128
184
 
129
- it('link 邀请:已有 pending 链接邀请 → 拒绝(单 pending link 语义)', async () => {
130
- vi.mocked(prisma.teamInvitation.findFirst).mockResolvedValue({ id: 'inv0' } as never)
131
- await expect(
132
- createTeamInvitationFn({ data: { seatType: 'member', channel: 'link' } }),
133
- ).rejects.toMatchObject({ message: expect.stringContaining('邀请链接') })
134
- })
185
+ describe('createTeamInvitationFn link 邀请', () => {
186
+ beforeEach(() => vi.clearAllMocks())
135
187
 
136
- it('create 触发 P2002 → 转译为友好错误', async () => {
188
+ it('link 邀请:无旧 pending 链接 → 创建成功', async () => {
137
189
  vi.mocked(prisma.teamInvitation.findFirst).mockResolvedValue(null)
138
- vi.mocked(prisma.teamInvitation.create).mockRejectedValue({ code: 'P2002' } as never)
139
- await expect(
140
- createTeamInvitationFn({ data: { seatType: 'member', channel: 'link' } }),
141
- ).rejects.toMatchObject({ message: expect.stringContaining('待处理') })
190
+ vi.mocked(prisma.teamInvitation.create).mockResolvedValue(
191
+ { id: 'inv1', token: 'abc' } as never,
192
+ )
193
+ const result = await createTeamInvitationFn({ data: { seatType: 'member', channel: 'link' } })
194
+ expect(result.success).toBe(true)
195
+ expect(result.invitationId).toBe('inv1')
196
+ })
197
+ })
198
+
199
+ describe('clearExpiredInvitationsFn', () => {
200
+ beforeEach(() => vi.clearAllMocks())
201
+
202
+ it('清除所有过期邀请', async () => {
203
+ vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 2 } as never)
204
+ const result = await clearExpiredInvitationsFn({ data: {} })
205
+ expect(result.success).toBe(true)
206
+ expect(result.count).toBe(2)
207
+ expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(
208
+ expect.objectContaining({
209
+ where: expect.objectContaining({ status: 'expired' }),
210
+ }),
211
+ )
142
212
  })
143
- })
213
+ })
@@ -0,0 +1,281 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@tanstack/react-start', () => ({
4
+ createServerFn: (config: any) => {
5
+ const api: any = {
6
+ _validator: undefined,
7
+ validator(schema: any) {
8
+ this._validator = schema
9
+ return this
10
+ },
11
+ handler(fn: Function) {
12
+ const wrapped = async (args: any) => {
13
+ let data = args?.data
14
+ if (this._validator && data !== undefined) {
15
+ data = this._validator.parse ? this._validator.parse(data) : data
16
+ }
17
+ return fn({ data })
18
+ }
19
+ return wrapped
20
+ },
21
+ }
22
+ return api
23
+ },
24
+ }))
25
+
26
+ vi.mock('@tanstack/react-start/server', () => ({
27
+ getRequest: vi.fn(),
28
+ }))
29
+
30
+ vi.mock('@allbluecn/database', () => ({
31
+ prisma: {
32
+ user: { findUnique: vi.fn() },
33
+ teamInvitation: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), create: vi.fn(), findMany: vi.fn() },
34
+ teamMembership: { findUnique: vi.fn(), create: vi.fn() },
35
+ teamJoinRequest: { findUnique: vi.fn(), upsert: vi.fn(), update: vi.fn(), updateMany: vi.fn(), findMany: vi.fn() },
36
+ $transaction: vi.fn(),
37
+ },
38
+ }))
39
+
40
+ vi.mock('@/server/auth-helpers', () => ({
41
+ requireAuth: vi.fn().mockResolvedValue({ user: { id: 'u2', email: 'b@x.com' } }),
42
+ }))
43
+
44
+ vi.mock('@/server/audit', () => ({
45
+ auditLog: vi.fn(),
46
+ }))
47
+
48
+ vi.mock('@/server/resources', () => ({
49
+ requireTeamSeats: vi.fn().mockResolvedValue(undefined),
50
+ }))
51
+
52
+ import { prisma } from '@allbluecn/database'
53
+ import { applyTeamInvitationFn, approveTeamJoinRequestFn, rejectTeamJoinRequestFn, getTeamInvitationByTokenFn, refreshTeamInvitationLinkFn, listTeamInvitationsFn } from '../team'
54
+
55
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn(prisma))
56
+
57
+ const LINK_INV = {
58
+ id: 'inv1', token: 't1', status: 'pending', seatType: 'member', channel: 'link',
59
+ email: null, expiresAt: new Date(Date.now() + 60000), ownerId: 'owner1',
60
+ owner: { id: 'owner1', username: 'ow', email: 'owner@x.com' },
61
+ }
62
+
63
+ describe('applyTeamInvitationFn(link 渠道)', () => {
64
+ beforeEach(() => vi.clearAllMocks())
65
+
66
+ it('链接不存在 → 拒绝', async () => {
67
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(null as never)
68
+ await expect(applyTeamInvitationFn({ data: { token: 'tX', email: 'a@x.com' } }))
69
+ .rejects.toMatchObject({ message: expect.stringContaining('不存在') })
70
+ })
71
+
72
+ it('链接过期 → 拒绝', async () => {
73
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({ ...LINK_INV, expiresAt: new Date(Date.now() - 1000) } as never)
74
+ await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'a@x.com' } }))
75
+ .rejects.toMatchObject({ message: expect.stringContaining('过期') })
76
+ })
77
+
78
+ it('邮箱是 owner 自己 → 拒绝(不能加入自己的团队)', async () => {
79
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
80
+ await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'owner@x.com' } }))
81
+ .rejects.toMatchObject({ message: expect.stringContaining('自己的团队') })
82
+ })
83
+
84
+ it('邮箱已注册且该账号已加入团队 → 拒绝', async () => {
85
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
86
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 'u9' } as never)
87
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'm1' } as never)
88
+ await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'taken@x.com' } }))
89
+ .rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
90
+ })
91
+
92
+ it('新邮箱 → 创建 TeamJoinRequest,且不改 TeamInvitation 状态', async () => {
93
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
94
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never)
95
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
96
+ vi.mocked(prisma.teamJoinRequest.upsert).mockResolvedValue({ id: 'jr1' } as never)
97
+
98
+ const res = await applyTeamInvitationFn({ data: { token: 't1', email: 'new@x.com' } })
99
+
100
+ expect(res).toMatchObject({ success: true, requiresApproval: true })
101
+ expect(prisma.teamJoinRequest.upsert).toHaveBeenCalledWith(expect.objectContaining({
102
+ where: { invitationId_email: { invitationId: 'inv1', email: 'new@x.com' } },
103
+ }))
104
+ expect(prisma.teamInvitation.update).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it('同链接同邮箱已有 pending 申请 → 幂等返回,不重复创建', async () => {
108
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
109
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never)
110
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ id: 'jr1', status: 'pending' } as never)
111
+
112
+ const res = await applyTeamInvitationFn({ data: { token: 't1', email: 'dup@x.com' } })
113
+
114
+ expect(res).toMatchObject({ success: true, duplicate: true })
115
+ expect(prisma.teamJoinRequest.upsert).not.toHaveBeenCalled()
116
+ })
117
+ })
118
+
119
+ const PENDING_REQ = {
120
+ id: 'jr1', invitationId: 'inv1', ownerId: 'u2', userId: 'u9',
121
+ email: 'new@x.com', status: 'pending', seatType: 'member',
122
+ invitation: { id: 'inv1', token: 't1', status: 'pending', channel: 'link', expiresAt: new Date(Date.now() + 60000) },
123
+ }
124
+
125
+ describe('approveTeamJoinRequestFn', () => {
126
+ beforeEach(() => vi.clearAllMocks())
127
+
128
+ it('申请不存在或非本人 → 拒绝', async () => {
129
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
130
+ await expect(approveTeamJoinRequestFn({ data: { requestId: 'jrX', seatType: 'member' } }))
131
+ .rejects.toMatchObject({ message: expect.stringContaining('申请不存在') })
132
+ })
133
+
134
+ it('申请人已加入其他团队 → 友好拒绝', async () => {
135
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
136
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'mX' } as never)
137
+ await expect(approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } }))
138
+ .rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
139
+ })
140
+
141
+ it('链接已过期 → 拒绝并同步失效申请', async () => {
142
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({
143
+ ...PENDING_REQ, invitation: { ...PENDING_REQ.invitation, expiresAt: new Date(Date.now() - 1000) },
144
+ } as never)
145
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
146
+ await expect(approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } }))
147
+ .rejects.toMatchObject({ message: expect.stringContaining('过期') })
148
+ expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
149
+ data: expect.objectContaining({ status: 'invalidated' }),
150
+ }))
151
+ })
152
+
153
+ it('批准 → 事务创建 membership 并置 approved', async () => {
154
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
155
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
156
+ vi.mocked(prisma.teamMembership.create).mockResolvedValue({ id: 'm1' } as never)
157
+ vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
158
+
159
+ const res = await approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'guest' } })
160
+
161
+ expect(res).toEqual({ success: true })
162
+ expect(prisma.teamMembership.create).toHaveBeenCalledWith(expect.objectContaining({
163
+ data: { ownerId: 'u2', memberId: 'u9', seatType: 'guest' },
164
+ }))
165
+ expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
166
+ where: { id: 'jr1' },
167
+ data: expect.objectContaining({ status: 'approved', seatType: 'guest' }),
168
+ }))
169
+ })
170
+
171
+ it('userId 为空时按 email 回查申请人', async () => {
172
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ ...PENDING_REQ, userId: null } as never)
173
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
174
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 'u9' } as never)
175
+ vi.mocked(prisma.teamMembership.create).mockResolvedValue({ id: 'm1' } as never)
176
+ vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
177
+
178
+ await approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } })
179
+
180
+ expect(prisma.user.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { email: 'new@x.com' } }))
181
+ expect(prisma.teamMembership.create).toHaveBeenCalledWith(expect.objectContaining({
182
+ data: expect.objectContaining({ memberId: 'u9' }),
183
+ }))
184
+ })
185
+ })
186
+
187
+ describe('rejectTeamJoinRequestFn', () => {
188
+ beforeEach(() => vi.clearAllMocks())
189
+
190
+ it('拒绝 → 置 rejected 并记录 processedAt', async () => {
191
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
192
+ vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
193
+
194
+ await rejectTeamJoinRequestFn({ data: { requestId: 'jr1' } })
195
+
196
+ expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
197
+ where: { id: 'jr1' },
198
+ data: expect.objectContaining({ status: 'rejected' }),
199
+ }))
200
+ })
201
+
202
+ it('非 pending 状态 → 拒绝操作', async () => {
203
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ ...PENDING_REQ, status: 'approved' } as never)
204
+ await expect(rejectTeamJoinRequestFn({ data: { requestId: 'jr1' } }))
205
+ .rejects.toMatchObject({ message: expect.stringContaining('待审批') })
206
+ })
207
+ })
208
+
209
+ describe('getTeamInvitationByTokenFn(link 渠道)', () => {
210
+ beforeEach(() => vi.clearAllMocks())
211
+
212
+ it('登录用户有 pending 申请 → 返回 myRequestStatus=pending', async () => {
213
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
214
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
215
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: 'new@x.com' } as never)
216
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ status: 'pending' } as never)
217
+
218
+ const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: 'u9' } })
219
+
220
+ expect(res).toMatchObject({ effectiveStatus: 'pending', myRequestStatus: 'pending', alreadyJoined: false })
221
+ })
222
+
223
+ it('链接过期 → 置 expired 并 invalid 关联 pending 申请', async () => {
224
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({ ...LINK_INV, expiresAt: new Date(Date.now() - 1000) } as never)
225
+ vi.mocked(prisma.teamInvitation.update).mockResolvedValue({} as never)
226
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 2 } as never)
227
+
228
+ const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: null } })
229
+
230
+ expect(res).toMatchObject({ effectiveStatus: 'expired' })
231
+ expect(prisma.teamJoinRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
232
+ where: { invitationId: 'inv1', status: 'pending' },
233
+ }))
234
+ })
235
+
236
+ it('登录用户已加入团队 → alreadyJoined=true', async () => {
237
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
238
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ ownerId: 'other' } as never)
239
+
240
+ const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: 'u9' } })
241
+
242
+ expect(res).toMatchObject({ alreadyJoined: true, joinedOwnerId: 'other', myRequestStatus: null })
243
+ })
244
+ })
245
+
246
+ describe('refreshTeamInvitationLinkFn', () => {
247
+ beforeEach(() => vi.clearAllMocks())
248
+
249
+ it('刷新 → 旧链接 pending 申请全部 invalidated,旧链接删除,新链接创建', async () => {
250
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 1 } as never)
251
+ vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 1 } as never)
252
+ vi.mocked(prisma.teamInvitation.create).mockResolvedValue({ id: 'inv2', token: 'NEW' } as never)
253
+
254
+ const res = await refreshTeamInvitationLinkFn({ data: { expiresInDays: 7 } })
255
+
256
+ expect(res).toMatchObject({ success: true })
257
+ expect(prisma.teamJoinRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
258
+ where: expect.objectContaining({ ownerId: 'u2', status: 'pending' }),
259
+ data: expect.objectContaining({ status: 'invalidated' }),
260
+ }))
261
+ expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(expect.objectContaining({
262
+ where: { ownerId: 'u2', channel: 'link', status: { in: ['pending', 'pending_approval'] } },
263
+ }))
264
+ expect(prisma.teamInvitation.create).toHaveBeenCalled()
265
+ })
266
+ })
267
+
268
+ describe('listTeamInvitationsFn', () => {
269
+ beforeEach(() => vi.clearAllMocks())
270
+
271
+ it('返回 { invitations, joinRequests } 结构', async () => {
272
+ vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 0 } as never)
273
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 0 } as never)
274
+ vi.mocked(prisma.teamInvitation.findMany).mockResolvedValue([{ id: 'inv1' }] as never)
275
+ vi.mocked(prisma.teamJoinRequest.findMany).mockResolvedValue([{ id: 'jr1' }] as never)
276
+
277
+ const res = await listTeamInvitationsFn()
278
+
279
+ expect(res).toEqual({ invitations: [{ id: 'inv1' }], joinRequests: [{ id: 'jr1' }] })
280
+ })
281
+ })