| 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 |