| 1 | import { AccountType, ResponseCode } from '@yikart/common' |
| 2 | import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 3 | import { ChannelPlatformException, PlatformErrorCategory, PlatformErrorCauseType } from '../platforms/platforms.exception' |
| 4 | import { AnalyticsService } from './analytics.service' |
| 5 | |
| 6 | vi.mock('@yikart/assets', async () => { |
| 7 | const { z } = await import('zod') |
| 8 | return { |
| 9 | assetsConfigSchema: z.object({}).passthrough(), |
| 10 | VideoMetadataService: class VideoMetadataService {}, |
| 11 | } |
| 12 | }) |
| 13 | |
| 14 | vi.mock('@yikart/channel-db', async () => { |
| 15 | const { z } = await import('zod') |
| 16 | return { |
| 17 | ChannelAccountDataSnapshotRepository: class ChannelAccountDataSnapshotRepository {}, |
| 18 | ChannelWorkDataSnapshotRepository: class ChannelWorkDataSnapshotRepository {}, |
| 19 | mongodbConfigSchema: z.object({}).passthrough(), |
| 20 | } |
| 21 | }) |
| 22 | |
| 23 | vi.mock('@yikart/mongodb', async () => { |
| 24 | const { z } = await import('zod') |
| 25 | return { |
| 26 | AccountRepository: class AccountRepository {}, |
| 27 | AssetType: { |
| 28 | AiImage: 'aiImage', |
| 29 | AiVideo: 'aiVideo', |
| 30 | AiCard: 'aiCard', |
| 31 | AiChatImage: 'aiChatImage', |
| 32 | AideoOutput: 'aideoOutput', |
| 33 | VideoEdit: 'videoEdit', |
| 34 | DramaRecap: 'dramaRecap', |
| 35 | StyleTransfer: 'styleTransfer', |
| 36 | ImageEdit: 'imageEdit', |
| 37 | Subtitle: 'subtitle', |
| 38 | UserMedia: 'userMedia', |
| 39 | UserFile: 'userFile', |
| 40 | PublishMedia: 'publishMedia', |
| 41 | Avatar: 'avatar', |
| 42 | AgentSession: 'agentSession', |
| 43 | VideoThumbnail: 'videoThumbnail', |
| 44 | GooglePlace: 'googlePlace', |
| 45 | Temp: 'temp', |
| 46 | }, |
| 47 | mongodbConfigSchema: z.object({}).passthrough(), |
| 48 | PublishRecordRepository: class PublishRecordRepository {}, |
| 49 | } |
| 50 | }) |
| 51 | |
| 52 | vi.mock('../relay/relay-account.exception', () => ({ |
| 53 | RelayAccountException: class RelayAccountException extends Error {}, |
| 54 | })) |
| 55 | |
| 56 | vi.mock('../auth/auth.service', () => ({ |
| 57 | AuthService: class AuthService {}, |
| 58 | })) |
| 59 | |
| 60 | function createService() { |
| 61 | const registry = { |
| 62 | getAnalytics: vi.fn(), |
| 63 | } |
| 64 | const authService = { |
| 65 | getValidCredential: vi.fn(), |
| 66 | refreshCredential: vi.fn(), |
| 67 | markAccountOfflineForCredentialFailure: vi.fn(async () => true), |
| 68 | } |
| 69 | const accountRepository = { |
| 70 | getByIdAndUserId: vi.fn(), |
| 71 | updateById: vi.fn(), |
| 72 | } |
| 73 | const accountSnapshotRepository = { |
| 74 | createMany: vi.fn(), |
| 75 | } |
| 76 | const workSnapshotRepository = { |
| 77 | createMany: vi.fn(async data => data.map((item: Record<string, unknown>, index: number) => ({ |
| 78 | ...item, |
| 79 | id: `snapshot-${index}`, |
| 80 | }))), |
| 81 | } |
| 82 | const service = new AnalyticsService( |
| 83 | registry as never, |
| 84 | authService as never, |
| 85 | accountRepository as never, |
| 86 | accountSnapshotRepository as never, |
| 87 | workSnapshotRepository as never, |
| 88 | ) |
| 89 | const logger = { error: vi.fn() } |
| 90 | Object.assign(service as unknown as { logger: typeof logger }, { logger }) |
| 91 | |
| 92 | return { |
| 93 | service, |
| 94 | registry, |
| 95 | authService, |
| 96 | accountRepository, |
| 97 | workSnapshotRepository, |
| 98 | logger, |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | describe('analytics service account analytics', () => { |
| 103 | beforeEach(() => { |
| 104 | vi.clearAllMocks() |
| 105 | }) |
| 106 | |
| 107 | it('refreshes YouTube credentials and retries account analytics before marking the account offline', async () => { |
| 108 | const { service, registry, authService, accountRepository } = createService() |
| 109 | const platformError = new ChannelPlatformException({ |
| 110 | code: ResponseCode.ChannelAccessTokenFailed, |
| 111 | platform: AccountType.YouTube, |
| 112 | category: PlatformErrorCategory.Auth, |
| 113 | retryable: false, |
| 114 | cause: { |
| 115 | type: PlatformErrorCauseType.Http, |
| 116 | httpStatus: 401, |
| 117 | }, |
| 118 | }) |
| 119 | const fetchAccountAnalytics = vi.fn() |
| 120 | const provider = { fetchAccountAnalytics } |
| 121 | let callCount = 0 |
| 122 | fetchAccountAnalytics.mockImplementation(function (this: unknown) { |
| 123 | expect(this).toBe(provider) |
| 124 | callCount += 1 |
| 125 | if (callCount === 1) { |
| 126 | return Promise.reject(platformError) |
| 127 | } |
| 128 | return Promise.resolve({ |
| 129 | snapshots: [], |
| 130 | metrics: { fansCount: 10 }, |
| 131 | }) |
| 132 | }) |
| 133 | registry.getAnalytics.mockReturnValue(provider) |
| 134 | accountRepository.getByIdAndUserId.mockResolvedValue({ |
| 135 | id: 'account-1', |
| 136 | type: AccountType.YouTube, |
| 137 | uid: 'channel-1', |
| 138 | }) |
| 139 | authService.getValidCredential.mockResolvedValue({ |
| 140 | accessToken: 'expired-token', |
| 141 | refreshToken: 'refresh-token', |
| 142 | scope: 'old-scope', |
| 143 | }) |
| 144 | authService.refreshCredential.mockResolvedValue({ |
| 145 | accessToken: 'refreshed-token', |
| 146 | refreshToken: 'refreshed-refresh-token', |
| 147 | expiresAt: new Date('2026-07-01T00:00:00.000Z'), |
| 148 | scope: 'new-scope', |
| 149 | }) |
| 150 | |
| 151 | const result = await service.fetchAccountAnalytics('user-1', 'account-1') |
| 152 | |
| 153 | expect(authService.refreshCredential).toHaveBeenCalledWith('account-1', 'user-1') |
| 154 | expect(fetchAccountAnalytics).toHaveBeenCalledTimes(2) |
| 155 | expect(fetchAccountAnalytics).toHaveBeenNthCalledWith(1, expect.objectContaining({ |
| 156 | credential: expect.objectContaining({ |
| 157 | accessToken: 'expired-token', |
| 158 | refreshToken: 'refresh-token', |
| 159 | scope: 'old-scope', |
| 160 | }), |
| 161 | })) |
| 162 | expect(fetchAccountAnalytics).toHaveBeenNthCalledWith(2, expect.objectContaining({ |
| 163 | credential: expect.objectContaining({ |
| 164 | accessToken: 'refreshed-token', |
| 165 | refreshToken: 'refreshed-refresh-token', |
| 166 | scope: 'new-scope', |
| 167 | }), |
| 168 | })) |
| 169 | expect(authService.markAccountOfflineForCredentialFailure).not.toHaveBeenCalled() |
| 170 | expect(result.metrics).toEqual({ fansCount: 10 }) |
| 171 | }) |
| 172 | }) |
| 173 | |
| 174 | describe('analytics service work analytics', () => { |
| 175 | beforeEach(() => { |
| 176 | vi.clearAllMocks() |
| 177 | }) |
| 178 | |
| 179 | it('uses official work analytics when it returns metrics', async () => { |
| 180 | const { service, registry, authService, accountRepository } = createService() |
| 181 | const fetchedAt = new Date('2026-06-01T00:00:00.000Z') |
| 182 | const fetchWorkAnalytics = vi.fn() |
| 183 | const provider = { fetchWorkAnalytics } |
| 184 | fetchWorkAnalytics.mockImplementation(function (this: unknown) { |
| 185 | expect(this).toBe(provider) |
| 186 | return Promise.resolve({ |
| 187 | snapshots: [{ |
| 188 | platformWorkId: 'video-1', |
| 189 | snapshotAt: fetchedAt, |
| 190 | fetchedAt, |
| 191 | work: { id: 'video-1' }, |
| 192 | metrics: { viewCount: 10 }, |
| 193 | }], |
| 194 | metrics: { viewCount: 10 }, |
| 195 | }) |
| 196 | }) |
| 197 | registry.getAnalytics.mockReturnValue(provider) |
| 198 | accountRepository.getByIdAndUserId.mockResolvedValue({ id: 'account-1', type: AccountType.YouTube, uid: 'uid-1' }) |
| 199 | authService.getValidCredential.mockResolvedValue({ accessToken: 'access-token', refreshToken: 'refresh-token' }) |
| 200 | |
| 201 | const result = await service.fetchWorkAnalytics('user-1', AccountType.YouTube, 'video-1', 'account-1') |
| 202 | |
| 203 | expect(fetchWorkAnalytics).toHaveBeenCalledWith({ |
| 204 | accountId: 'account-1', |
| 205 | platformWorkId: 'video-1', |
| 206 | platform: AccountType.YouTube, |
| 207 | credential: { |
| 208 | accessToken: 'access-token', |
| 209 | refreshToken: 'refresh-token', |
| 210 | expiresAt: undefined, |
| 211 | scope: undefined, |
| 212 | platformUid: 'uid-1', |
| 213 | account: undefined, |
| 214 | }, |
| 215 | since: undefined, |
| 216 | until: undefined, |
| 217 | }) |
| 218 | expect(result.metrics).toEqual({ viewCount: 10 }) |
| 219 | }) |
| 220 | |
| 221 | it('rethrows official work analytics failures after logging the platform error', async () => { |
| 222 | const { service, registry, authService, accountRepository, logger } = createService() |
| 223 | const platformError = new Error('platform failed') |
| 224 | registry.getAnalytics.mockReturnValue({ |
| 225 | fetchWorkAnalytics: vi.fn().mockRejectedValue(platformError), |
| 226 | }) |
| 227 | accountRepository.getByIdAndUserId.mockResolvedValue({ id: 'account-1', type: AccountType.YouTube, uid: 'uid-1' }) |
| 228 | authService.getValidCredential.mockResolvedValue({ accessToken: 'access-token' }) |
| 229 | |
| 230 | await expect(service.fetchWorkAnalytics('user-1', AccountType.YouTube, 'video-1', 'account-1')) |
| 231 | .rejects |
| 232 | .toBe(platformError) |
| 233 | |
| 234 | expect(logger.error).toHaveBeenCalledWith( |
| 235 | platformError, |
| 236 | 'Fetch channel work analytics from platform failed: platform=youtube, accountId=account-1, platformWorkId=video-1', |
| 237 | ) |
| 238 | }) |
| 239 | |
| 240 | it('marks the account offline when official work analytics returns an auth failure', async () => { |
| 241 | const { service, registry, authService, accountRepository } = createService() |
| 242 | const platformError = new ChannelPlatformException({ |
| 243 | code: ResponseCode.ChannelAccessTokenFailed, |
| 244 | platform: AccountType.YouTube, |
| 245 | category: PlatformErrorCategory.Auth, |
| 246 | retryable: false, |
| 247 | cause: { |
| 248 | type: PlatformErrorCauseType.Http, |
| 249 | httpStatus: 401, |
| 250 | }, |
| 251 | }) |
| 252 | registry.getAnalytics.mockReturnValue({ |
| 253 | fetchWorkAnalytics: vi.fn().mockRejectedValue(platformError), |
| 254 | }) |
| 255 | accountRepository.getByIdAndUserId.mockResolvedValue({ id: 'account-1', type: AccountType.YouTube, uid: 'uid-1' }) |
| 256 | authService.getValidCredential.mockResolvedValue({ accessToken: 'access-token' }) |
| 257 | |
| 258 | await expect(service.fetchWorkAnalytics('user-1', AccountType.YouTube, 'video-1', 'account-1')) |
| 259 | .rejects |
| 260 | .toBe(platformError) |
| 261 | |
| 262 | expect(authService.markAccountOfflineForCredentialFailure).toHaveBeenCalledWith( |
| 263 | 'account-1', |
| 264 | platformError, |
| 265 | 'platform_auth_failed', |
| 266 | ) |
| 267 | }) |
| 268 | |
| 269 | it('returns unsupported when there is no official work analytics provider', async () => { |
| 270 | const { service, registry, authService, accountRepository } = createService() |
| 271 | registry.getAnalytics.mockReturnValue(undefined) |
| 272 | accountRepository.getByIdAndUserId.mockResolvedValue({ id: 'account-1', type: AccountType.RedNote }) |
| 273 | |
| 274 | const result = await service.fetchWorkAnalytics('user-1', AccountType.RedNote, 'note-1', 'account-1') |
| 275 | |
| 276 | expect(authService.getValidCredential).not.toHaveBeenCalled() |
| 277 | expect(result).toEqual({ |
| 278 | platform: AccountType.RedNote, |
| 279 | accountId: 'account-1', |
| 280 | platformWorkId: 'note-1', |
| 281 | snapshots: [], |
| 282 | message: 'Work analytics not supported', |
| 283 | }) |
| 284 | }) |
| 285 | }) |
| 286 |