返回 AiToEarn
analytics.service.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-server / src / core / channels / analytics / analytics.service.spec.ts
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
286 lines TYPESCRIPT