返回 AiToEarn
credential.service.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-server / src / core / channels / accounts / credential.service.spec.ts
1 import { AccountType, AppException, ResponseCode } from '@yikart/common'
2 import { beforeEach, describe, expect, it, vi } from 'vitest'
3 import { CredentialService } from './credential.service'
4
5 vi.mock('@yikart/mongodb', () => ({
6 OAuth2CredentialRepository: class OAuth2CredentialRepository {},
7 Transactional: () => () => undefined,
8 }))
9
10 vi.mock('../platforms/platforms.registry', () => ({
11 PlatformIntegrationRegistry: class PlatformIntegrationRegistry {},
12 }))
13
14 function createService(overrides: {
15 credentialRepo?: unknown
16 redis?: unknown
17 registry?: unknown
18 refresh?: unknown
19 } = {}) {
20 const credentialRepo = overrides.credentialRepo as {
21 getByAccountId: ReturnType<typeof vi.fn>
22 createOrUpdateByAccountId: ReturnType<typeof vi.fn>
23 deleteByAccountId: ReturnType<typeof vi.fn>
24 listByAccessTokenExpiresAt: ReturnType<typeof vi.fn>
25 listByAccessTokenExpiresAtAndNormalAccount: ReturnType<typeof vi.fn>
26 } ?? {
27 getByAccountId: vi.fn(),
28 createOrUpdateByAccountId: vi.fn(async (
29 accountId: string,
30 platform: AccountType,
31 data: Record<string, unknown>,
32 ) => ({ id: 'credential-1', accountId, platform, ...data })),
33 deleteByAccountId: vi.fn(async () => true),
34 listByAccessTokenExpiresAt: vi.fn(),
35 listByAccessTokenExpiresAtAndNormalAccount: vi.fn(),
36 }
37 const redis = overrides.redis as {
38 getChannelCredentialCache: ReturnType<typeof vi.fn>
39 saveChannelCredentialCache: ReturnType<typeof vi.fn>
40 deleteChannelCredentialCache: ReturnType<typeof vi.fn>
41 acquireChannelCredentialRefreshLock: ReturnType<typeof vi.fn>
42 releaseChannelCredentialRefreshLock: ReturnType<typeof vi.fn>
43 } ?? {
44 getChannelCredentialCache: vi.fn(),
45 saveChannelCredentialCache: vi.fn(),
46 deleteChannelCredentialCache: vi.fn(),
47 acquireChannelCredentialRefreshLock: vi.fn(),
48 releaseChannelCredentialRefreshLock: vi.fn(),
49 }
50 const refresh = overrides.refresh as ReturnType<typeof vi.fn> ?? vi.fn(async () => ({
51 accessToken: 'new-access-token',
52 refreshToken: 'new-refresh-token',
53 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
54 scope: 'video.list',
55 raw: { token: 'raw-token' },
56 }))
57 const registry = overrides.registry as { getAuth: ReturnType<typeof vi.fn> } ?? {
58 getAuth: vi.fn(() => ({ refresh })),
59 }
60
61 return {
62 service: new CredentialService(credentialRepo as never, redis as never, registry as never),
63 credentialRepo,
64 redis,
65 registry,
66 refresh,
67 }
68 }
69
70 describe('credential service', () => {
71 beforeEach(() => {
72 vi.clearAllMocks()
73 })
74
75 it('preserves stored refresh token when provider omits it', async () => {
76 const { service, credentialRepo, redis } = createService()
77 await service.saveCredential('account-1', AccountType.Facebook, {
78 accessToken: 'page-access-token',
79 refreshToken: undefined,
80 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
81 })
82
83 expect(credentialRepo.createOrUpdateByAccountId).toHaveBeenCalledWith('account-1', AccountType.Facebook, {
84 accessToken: 'page-access-token',
85 accessTokenExpiresAt: 1767225600,
86 refreshToken: undefined,
87 scope: undefined,
88 raw: undefined,
89 })
90 expect(redis.deleteChannelCredentialCache).toHaveBeenCalledWith('account-1')
91 })
92
93 it('saves refresh token when provider returns it', async () => {
94 const { service, credentialRepo } = createService()
95
96 await service.saveCredential('account-1', AccountType.Facebook, {
97 accessToken: 'page-access-token',
98 refreshToken: 'page-refresh-token',
99 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
100 })
101
102 expect(credentialRepo.createOrUpdateByAccountId).toHaveBeenCalledWith('account-1', AccountType.Facebook, {
103 accessToken: 'page-access-token',
104 refreshToken: 'page-refresh-token',
105 accessTokenExpiresAt: 1767225600,
106 scope: undefined,
107 raw: undefined,
108 })
109 })
110
111 it('clears stored access token expiry when provider omits expiresAt', async () => {
112 const { service, credentialRepo } = createService()
113
114 await service.saveCredential('account-1', AccountType.Facebook, {
115 accessToken: 'long-lived-access-token',
116 })
117
118 expect(credentialRepo.createOrUpdateByAccountId).toHaveBeenCalledWith('account-1', AccountType.Facebook, {
119 accessToken: 'long-lived-access-token',
120 accessTokenExpiresAt: undefined,
121 refreshToken: undefined,
122 scope: undefined,
123 raw: undefined,
124 })
125 })
126
127 it('keeps credential scope in storage and cache reads', async () => {
128 const { service, credentialRepo, redis } = createService()
129 redis.getChannelCredentialCache.mockResolvedValue(null)
130 credentialRepo.getByAccountId.mockResolvedValue({
131 accountId: 'account-1',
132 accessToken: 'access-token',
133 refreshToken: 'refresh-token',
134 accessTokenExpiresAt: 1767225600,
135 scope: 'video.publish',
136 raw: { provider: 'facebook' },
137 })
138
139 await expect(service.getCredential('account-1')).resolves.toEqual({
140 accessToken: 'access-token',
141 refreshToken: 'refresh-token',
142 expiresAt: 1767225600,
143 scope: 'video.publish',
144 raw: { provider: 'facebook' },
145 })
146 expect(redis.saveChannelCredentialCache).toHaveBeenCalledWith('account-1', expect.objectContaining({
147 scope: 'video.publish',
148 }))
149 })
150
151 it('saves credential scope when provider returns it', async () => {
152 const { service, credentialRepo } = createService()
153
154 await service.saveCredential('account-1', AccountType.Facebook, {
155 accessToken: 'page-access-token',
156 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
157 scope: 'pages_manage_posts',
158 })
159
160 expect(credentialRepo.createOrUpdateByAccountId).toHaveBeenCalledWith('account-1', AccountType.Facebook, expect.objectContaining({
161 scope: 'pages_manage_posts',
162 }))
163 })
164
165 it('lists expiring credentials with a caller supplied limit', async () => {
166 const { service, credentialRepo } = createService()
167 credentialRepo.listByAccessTokenExpiresAtAndNormalAccount.mockResolvedValue([{
168 cursorId: 'cursor-id',
169 accountId: 'account-1',
170 platform: AccountType.Facebook,
171 accessTokenExpiresAt: 1767225600,
172 }])
173
174 await expect(service.listExpiringCredentials(1767225600, 100)).resolves.toEqual([{
175 cursorId: 'cursor-id',
176 accountId: 'account-1',
177 platform: AccountType.Facebook,
178 accessTokenExpiresAt: 1767225600,
179 refreshTokenExpiresAt: undefined,
180 }])
181 expect(credentialRepo.listByAccessTokenExpiresAtAndNormalAccount).toHaveBeenCalledWith(1767225600, 100, undefined)
182 })
183
184 it('deletes credential records and cache entries together', async () => {
185 const { service, credentialRepo, redis } = createService()
186
187 await service.deleteCredential('account-1')
188
189 expect(credentialRepo.deleteByAccountId).toHaveBeenCalledWith('account-1')
190 expect(redis.deleteChannelCredentialCache).toHaveBeenCalledWith('account-1')
191 })
192
193 it('uses an owner token for credential refresh locks', async () => {
194 const { service, redis } = createService()
195 redis.acquireChannelCredentialRefreshLock.mockResolvedValue(true)
196
197 const token = await service.lockRefresh('account-1')
198
199 expect(token).toEqual(expect.any(String))
200 expect(redis.acquireChannelCredentialRefreshLock).toHaveBeenCalledWith('account-1', token)
201
202 await service.unlockRefresh('account-1', token!)
203
204 expect(redis.releaseChannelCredentialRefreshLock).toHaveBeenCalledWith('account-1', token)
205 })
206
207 it('returns null when another refresh owns the lock', async () => {
208 const { service, redis, credentialRepo, registry } = createService()
209 redis.acquireChannelCredentialRefreshLock.mockResolvedValue(false)
210
211 await expect(service.tryRefresh({
212 id: 'account-1',
213 type: AccountType.TikTok,
214 })).resolves.toBeNull()
215
216 expect(credentialRepo.getByAccountId).not.toHaveBeenCalled()
217 expect(credentialRepo.createOrUpdateByAccountId).not.toHaveBeenCalled()
218 expect(redis.releaseChannelCredentialRefreshLock).not.toHaveBeenCalled()
219 expect(registry.getAuth).not.toHaveBeenCalled()
220 })
221
222 it('refreshes, saves, and returns the refreshed credential metadata', async () => {
223 const { service, credentialRepo, redis, refresh } = createService()
224 redis.acquireChannelCredentialRefreshLock.mockResolvedValue(true)
225 redis.getChannelCredentialCache.mockResolvedValue(null)
226 credentialRepo.getByAccountId.mockResolvedValue({
227 accessToken: 'access-token',
228 refreshToken: 'refresh-token',
229 scope: 'user.info',
230 })
231
232 await expect(service.tryRefresh({
233 id: 'account-1',
234 type: AccountType.TikTok,
235 })).resolves.toEqual({
236 accessToken: 'new-access-token',
237 refreshToken: 'new-refresh-token',
238 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
239 scope: 'video.list',
240 })
241
242 expect(refresh).toHaveBeenCalledWith({
243 accessToken: 'access-token',
244 refreshToken: 'refresh-token',
245 })
246 expect(credentialRepo.createOrUpdateByAccountId).toHaveBeenCalledWith('account-1', AccountType.TikTok, {
247 accessToken: 'new-access-token',
248 refreshToken: 'new-refresh-token',
249 accessTokenExpiresAt: 1767225600,
250 scope: 'video.list',
251 raw: { token: 'raw-token' },
252 })
253 expect(redis.releaseChannelCredentialRefreshLock).toHaveBeenCalledWith('account-1', expect.any(String))
254 })
255
256 it('keeps the existing refresh token and scope when the provider omits them', async () => {
257 const { service, credentialRepo, redis } = createService({
258 refresh: vi.fn(async () => ({
259 accessToken: 'new-access-token',
260 })),
261 })
262 redis.acquireChannelCredentialRefreshLock.mockResolvedValue(true)
263 redis.getChannelCredentialCache.mockResolvedValue(null)
264 credentialRepo.getByAccountId.mockResolvedValue({
265 accessToken: 'access-token',
266 refreshToken: 'refresh-token',
267 scope: 'user.info',
268 })
269
270 await expect(service.tryRefresh({
271 id: 'account-1',
272 type: AccountType.TikTok,
273 })).resolves.toEqual({
274 accessToken: 'new-access-token',
275 refreshToken: 'refresh-token',
276 scope: 'user.info',
277 })
278 })
279
280 it('releases the refresh lock when credential is missing', async () => {
281 const { service, redis } = createService()
282 redis.acquireChannelCredentialRefreshLock.mockResolvedValue(true)
283 redis.getChannelCredentialCache.mockResolvedValue(null)
284
285 await expect(service.tryRefresh({
286 id: 'account-1',
287 type: AccountType.YouTube,
288 }))
289 .rejects
290 .toMatchObject({
291 code: ResponseCode.ChannelCredentialNotFound,
292 })
293
294 expect(redis.releaseChannelCredentialRefreshLock).toHaveBeenCalledWith('account-1', expect.any(String))
295 })
296
297 it('rejects refresh when provider returns no access token', async () => {
298 const { service, credentialRepo, redis } = createService({
299 refresh: vi.fn(async () => ({
300 refreshToken: 'new-refresh-token',
301 })),
302 })
303 redis.acquireChannelCredentialRefreshLock.mockResolvedValue(true)
304 redis.getChannelCredentialCache.mockResolvedValue(null)
305 credentialRepo.getByAccountId.mockResolvedValue({
306 accessToken: 'access-token',
307 refreshToken: 'refresh-token',
308 })
309
310 await expect(service.tryRefresh({
311 id: 'account-1',
312 type: AccountType.YouTube,
313 }))
314 .rejects
315 .toMatchObject({
316 code: ResponseCode.ChannelAccessTokenFailed,
317 })
318
319 expect(credentialRepo.createOrUpdateByAccountId).not.toHaveBeenCalled()
320 expect(redis.releaseChannelCredentialRefreshLock).toHaveBeenCalledWith('account-1', expect.any(String))
321 })
322
323 it('releases the refresh lock when provider refresh fails', async () => {
324 const { service, credentialRepo, redis } = createService({
325 refresh: vi.fn(async () => {
326 throw new AppException(ResponseCode.ChannelRefreshTokenFailed)
327 }),
328 })
329 redis.acquireChannelCredentialRefreshLock.mockResolvedValue(true)
330 redis.getChannelCredentialCache.mockResolvedValue(null)
331 credentialRepo.getByAccountId.mockResolvedValue({
332 accessToken: 'access-token',
333 refreshToken: 'refresh-token',
334 })
335
336 await expect(service.tryRefresh({
337 id: 'account-1',
338 type: AccountType.YouTube,
339 }))
340 .rejects
341 .toMatchObject({
342 code: ResponseCode.ChannelRefreshTokenFailed,
343 })
344
345 expect(redis.releaseChannelCredentialRefreshLock).toHaveBeenCalledWith('account-1', expect.any(String))
346 })
347 })
348
348 lines TYPESCRIPT