返回 AiToEarn
work.service.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-server / src / core / channels / works / work.service.spec.ts
1 import { AccountType, ResponseCode } from '@yikart/common'
2 import { describe, expect, it, vi } from 'vitest'
3 import { ChannelPlatformException, PlatformErrorCategory, PlatformErrorCauseType } from '../platforms/platforms.exception'
4 import { AuthType } from '../platforms/platforms.interface'
5 import { WorkService } from './work.service'
6
7 vi.mock('@yikart/mongodb', () => ({
8 AssetType: {
9 AiImage: 'aiImage',
10 AiVideo: 'aiVideo',
11 AiCard: 'aiCard',
12 AiChatImage: 'aiChatImage',
13 AideoOutput: 'aideoOutput',
14 VideoEdit: 'videoEdit',
15 DramaRecap: 'dramaRecap',
16 StyleTransfer: 'styleTransfer',
17 ImageEdit: 'imageEdit',
18 Subtitle: 'subtitle',
19 UserMedia: 'userMedia',
20 UserFile: 'userFile',
21 PublishMedia: 'publishMedia',
22 Avatar: 'avatar',
23 AgentSession: 'agentSession',
24 VideoThumbnail: 'videoThumbnail',
25 GooglePlace: 'googlePlace',
26 Temp: 'temp',
27 },
28 AccountRepository: class AccountRepository {},
29 OAuth2CredentialRepository: class OAuth2CredentialRepository {},
30 }))
31
32 vi.mock('@yikart/channel-db', () => ({
33 ChannelWorkDataSnapshotRepository: class ChannelWorkDataSnapshotRepository {},
34 }))
35
36 vi.mock('../auth/auth.service', () => ({
37 AuthService: class AuthService {},
38 }))
39
40 describe('work service', () => {
41 it('uses the explicit platform route parameter for work detail', async () => {
42 const fetchedAt = new Date('2026-06-01T00:00:00.000Z')
43 const getDetail = vi.fn()
44 const provider = { getDetail }
45 getDetail.mockImplementation(function (this: unknown) {
46 expect(this).toBe(provider)
47 return Promise.resolve({
48 snapshots: [{
49 platformWorkId: 'video-id',
50 snapshotAt: fetchedAt,
51 fetchedAt,
52 work: { id: 'video-id' },
53 }],
54 })
55 })
56 const registry = {
57 getWork: vi.fn().mockReturnValue(provider),
58 }
59 const authService = {
60 getValidCredential: vi.fn().mockResolvedValue({ accessToken: 'access-token', refreshToken: 'refresh-token' }),
61 }
62 const accountRepository = {
63 getByIdAndUserId: vi.fn().mockResolvedValue({ id: 'account-id', type: AccountType.YouTube }),
64 }
65 const workSnapshotRepository = {
66 createMany: vi.fn(async data => data.map((item: Record<string, unknown>, index: number) => ({ ...item, id: `snapshot-${index}` }))),
67 }
68 const service = new WorkService(registry as never, authService as never, accountRepository as never, workSnapshotRepository as never)
69
70 const result = await service.getDetail('user-id', AccountType.YouTube, 'video-id', 'account-id')
71
72 expect(registry.getWork).toHaveBeenCalledWith(AccountType.YouTube)
73 expect(getDetail).toHaveBeenCalledWith({
74 accountId: 'account-id',
75 platform: AccountType.YouTube,
76 platformWorkId: 'video-id',
77 credential: { accessToken: 'access-token', refreshToken: 'refresh-token' },
78 })
79 expect(workSnapshotRepository.createMany).toHaveBeenCalledWith([{
80 userId: 'user-id',
81 platform: AccountType.YouTube,
82 accountId: 'account-id',
83 platformWorkId: 'video-id',
84 snapshotAt: fetchedAt,
85 fetchedAt,
86 periodStartAt: undefined,
87 periodEndAt: undefined,
88 work: { id: 'video-id' },
89 metrics: undefined,
90 extra: undefined,
91 rawResponse: undefined,
92 }])
93 expect(result).toEqual({
94 platform: AccountType.YouTube,
95 work: { id: 'video-id' },
96 snapshots: [{
97 id: 'snapshot-0',
98 userId: 'user-id',
99 platform: AccountType.YouTube,
100 accountId: 'account-id',
101 platformWorkId: 'video-id',
102 snapshotAt: fetchedAt,
103 fetchedAt,
104 periodStartAt: undefined,
105 periodEndAt: undefined,
106 work: { id: 'video-id' },
107 metrics: undefined,
108 extra: undefined,
109 rawResponse: undefined,
110 }],
111 extra: undefined,
112 snapshotId: 'snapshot-0',
113 fetchedAt,
114 })
115 })
116
117 it('rejects accounts from another platform', async () => {
118 const registry = {
119 getWork: vi.fn().mockReturnValue({ getDetail: vi.fn() }),
120 }
121 const authService = {
122 getValidCredential: vi.fn(),
123 }
124 const accountRepository = {
125 getByIdAndUserId: vi.fn().mockResolvedValue({ id: 'account-id', type: AccountType.TikTok }),
126 }
127 const service = new WorkService(registry as never, authService as never, accountRepository as never, {} as never)
128
129 await expect(service.getDetail('user-id', AccountType.YouTube, 'video-id', 'account-id'))
130 .rejects
131 .toMatchObject({ code: ResponseCode.ChannelAuthPlatformMismatch })
132 expect(authService.getValidCredential).not.toHaveBeenCalled()
133 })
134
135 it('marks the account offline when work provider returns an auth failure', async () => {
136 const platformError = new ChannelPlatformException({
137 code: ResponseCode.ChannelAccessTokenFailed,
138 platform: AccountType.YouTube,
139 category: PlatformErrorCategory.Auth,
140 retryable: false,
141 cause: {
142 type: PlatformErrorCauseType.Http,
143 httpStatus: 401,
144 },
145 })
146 const getDetail = vi.fn(async () => {
147 throw platformError
148 })
149 const registry = {
150 getWork: vi.fn().mockReturnValue({ getDetail }),
151 }
152 const authService = {
153 getValidCredential: vi.fn().mockResolvedValue({ accessToken: 'access-token' }),
154 markAccountOfflineForCredentialFailure: vi.fn(async () => true),
155 }
156 const accountRepository = {
157 getByIdAndUserId: vi.fn().mockResolvedValue({ id: 'account-id', type: AccountType.YouTube }),
158 }
159 const service = new WorkService(registry as never, authService as never, accountRepository as never, {} as never)
160
161 await expect(service.getDetail('user-id', AccountType.YouTube, 'video-id', 'account-id'))
162 .rejects
163 .toBe(platformError)
164
165 expect(authService.markAccountOfflineForCredentialFailure).toHaveBeenCalledWith(
166 'account-id',
167 platformError,
168 'platform_auth_failed',
169 )
170 })
171
172 it('throws when work detail capability is not supported', async () => {
173 const registry = {
174 getWork: vi.fn().mockReturnValue({}),
175 }
176 const authService = {
177 getValidCredential: vi.fn(),
178 }
179 const accountRepository = {
180 getByIdAndUserId: vi.fn(),
181 }
182 const service = new WorkService(registry as never, authService as never, accountRepository as never, {} as never)
183
184 await expect(service.getDetail('user-id', AccountType.YouTube, 'video-id', 'account-id'))
185 .rejects
186 .toMatchObject({
187 code: ResponseCode.ChannelPlatformOperationNotSupported,
188 })
189 expect(accountRepository.getByIdAndUserId).not.toHaveBeenCalled()
190 expect(authService.getValidCredential).not.toHaveBeenCalled()
191 })
192
193 it('does not infer work context from platformWorkId when account is missing', async () => {
194 const registry = {
195 getWork: vi.fn().mockReturnValue({ getDetail: vi.fn() }),
196 }
197 const authService = {
198 getValidCredential: vi.fn(),
199 }
200 const accountRepository = {
201 getByIdAndUserId: vi.fn(),
202 }
203 const service = new WorkService(registry as never, authService as never, accountRepository as never, {} as never)
204
205 await expect(service.getDetail('user-id', AccountType.YouTube, 'video-id'))
206 .rejects
207 .toMatchObject({ code: ResponseCode.AccountAuthRequired })
208 expect(accountRepository.getByIdAndUserId).not.toHaveBeenCalled()
209 expect(authService.getValidCredential).not.toHaveBeenCalled()
210 })
211
212 it('does not load credentials for plugin platform link info', async () => {
213 const getLinkInfo = vi.fn().mockResolvedValue({
214 snapshots: [],
215 work: { id: 'feed-id', url: 'https://channels.weixin.qq.com/web/pages/feed?feedId=feed-id' },
216 })
217 const registry = {
218 get: vi.fn().mockReturnValue({
219 metadata: { authType: AuthType.Plugin },
220 work: { getLinkInfo },
221 }),
222 }
223 const authService = {
224 getValidCredential: vi.fn(),
225 }
226 const accountRepository = {
227 getByIdAndUserId: vi.fn().mockResolvedValue({ id: 'wxSph_uid-1', type: AccountType.WeChatChannels }),
228 }
229 const workSnapshotRepository = {
230 createMany: vi.fn(async () => []),
231 }
232 const service = new WorkService(registry as never, authService as never, accountRepository as never, workSnapshotRepository as never)
233
234 await expect(service.getLinkInfo('user-id', AccountType.WeChatChannels, 'https://weixin.qq.com/sph/short-id', 'wxSph_uid-1', 'platform-work-id'))
235 .resolves
236 .toMatchObject({
237 platform: AccountType.WeChatChannels,
238 work: { id: 'feed-id' },
239 })
240
241 expect(registry.get).toHaveBeenCalledWith(AccountType.WeChatChannels)
242 expect(accountRepository.getByIdAndUserId).toHaveBeenCalledWith('wxSph_uid-1', 'user-id')
243 expect(authService.getValidCredential).not.toHaveBeenCalled()
244 expect(getLinkInfo).toHaveBeenCalledWith({
245 accountId: 'wxSph_uid-1',
246 platform: AccountType.WeChatChannels,
247 credential: undefined,
248 link: 'https://weixin.qq.com/sph/short-id',
249 dataId: 'platform-work-id',
250 })
251 })
252
253 it('loads credentials for oauth platform link info', async () => {
254 const getLinkInfo = vi.fn().mockResolvedValue({
255 snapshots: [],
256 work: { id: 'video-id' },
257 })
258 const registry = {
259 get: vi.fn().mockReturnValue({
260 metadata: { authType: AuthType.OAuth2 },
261 work: { getLinkInfo },
262 }),
263 }
264 const authService = {
265 getValidCredential: vi.fn().mockResolvedValue({ accessToken: 'access-token', refreshToken: 'refresh-token' }),
266 }
267 const accountRepository = {
268 getByIdAndUserId: vi.fn().mockResolvedValue({ id: 'account-id', type: AccountType.YouTube, uid: 'channel-id' }),
269 }
270 const workSnapshotRepository = {
271 createMany: vi.fn(async () => []),
272 }
273 const service = new WorkService(registry as never, authService as never, accountRepository as never, workSnapshotRepository as never)
274
275 await service.getLinkInfo('user-id', AccountType.YouTube, 'https://youtube.com/watch?v=video-id', 'account-id')
276
277 expect(authService.getValidCredential).toHaveBeenCalledWith('account-id', 'user-id')
278 expect(getLinkInfo).toHaveBeenCalledWith({
279 accountId: 'account-id',
280 platform: AccountType.YouTube,
281 credential: {
282 accessToken: 'access-token',
283 refreshToken: 'refresh-token',
284 platformUid: 'channel-id',
285 account: undefined,
286 },
287 link: 'https://youtube.com/watch?v=video-id',
288 })
289 })
290 })
291
291 lines TYPESCRIPT