返回 AiToEarn
publish.schema.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-server / src / core / channels / platforms / publish.schema.spec.ts
1 import { AccountType } from '@yikart/common'
2 import { describe, expect, it } from 'vitest'
3 import { z } from 'zod'
4 import { BilibiliOptionSchema } from './bilibili/bilibili.schema'
5 import { DouyinDownloadType, DouyinOptionSchema, DouyinPrivateStatus } from './douyin/douyin.schema'
6 import { FacebookContentCategory, FacebookOptionSchema } from './facebook/facebook.schema'
7 import { InstagramMediaType, InstagramOptionSchema } from './instagram/instagram.schema'
8 import {
9 formatPublishValidationIssue,
10 parseTopicInsertionsFromBody,
11 parseTopicsFromBody,
12 PlatformPublishOptionItemSchema,
13 PublishValidationField,
14 PublishValidationIssueCode,
15 stripTopicsFromBody,
16 } from './publish.schema'
17 import { ThreadsOptionSchema, ThreadsReplyControl } from './threads/threads.schema'
18 import { TiktokOptionSchema, TikTokPrivacyLevel } from './tiktok/tiktok.schema'
19 import { TwitterOptionSchema } from './twitter/twitter.schema'
20 import { YoutubeOptionSchema } from './youtube/youtube.schema'
21
22 describe('platform publish option schemas', () => {
23 it('exposes descriptions for nested option schema fields', () => {
24 expect(getJsonSchemaProperty(TwitterOptionSchema, 'poll', 'options').description).toBe('投票选项')
25 expect(getJsonSchemaProperty(TwitterOptionSchema, 'poll', 'duration_minutes').description).toBe('投票持续分钟数')
26 expect(getJsonSchemaProperty(InstagramOptionSchema, 'product_tags', 'product_id').description).toBe('商品 ID')
27 expect(getJsonSchemaProperty(InstagramOptionSchema, 'user_tags', 'username').description).toBe('用户名称')
28 expect(getJsonSchemaRootProperty(DouyinOptionSchema, 'download_type').description).toBe('下载类型 1-允许,2-不允许')
29 })
30
31 it('requires Bilibili tid and supports optional mission id', () => {
32 expect(BilibiliOptionSchema.safeParse({}).success).toBe(false)
33 expect(BilibiliOptionSchema.safeParse({
34 tid: 21,
35 mission_id: 123,
36 }).success).toBe(true)
37 })
38
39 it('keeps YouTube publishAt as an RFC3339 timestamp', () => {
40 expect(YoutubeOptionSchema.safeParse({ publishAt: '2026-05-22T10:00:00.000Z' }).success).toBe(true)
41 expect(YoutubeOptionSchema.safeParse({ publishAt: '2026-05-22 10:00:00' }).success).toBe(false)
42 })
43
44 it('does not expose YouTube tags as an option field', () => {
45 expect(YoutubeOptionSchema.parse({ tags: ['legacy'] })).not.toHaveProperty('tags')
46 })
47
48 it('limits Instagram captions to the official caption size', () => {
49 expect(InstagramOptionSchema.safeParse({ caption: 'a'.repeat(2200) }).success).toBe(true)
50 expect(InstagramOptionSchema.safeParse({ caption: 'a'.repeat(2201) }).success).toBe(false)
51 })
52
53 it('uses Instagram media_type instead of isReel', () => {
54 expect(InstagramOptionSchema.safeParse({ media_type: InstagramMediaType.Image }).success).toBe(true)
55 expect(InstagramOptionSchema.safeParse({ media_type: InstagramMediaType.Reels }).success).toBe(true)
56 expect(InstagramOptionSchema.safeParse({ media_type: InstagramMediaType.Stories }).success).toBe(true)
57 expect(InstagramOptionSchema.safeParse({ media_type: InstagramMediaType.Carousel }).success).toBe(true)
58 expect(InstagramOptionSchema.safeParse({ media_type: 'VIDEO' }).success).toBe(false)
59 expect(InstagramOptionSchema.parse({ isReel: true, content_category: 'reel' })).toEqual({})
60 })
61
62 it('uses explicit Facebook content categories and rejects legacy target fields', () => {
63 expect(FacebookOptionSchema.safeParse({ content_category: FacebookContentCategory.Reel }).success).toBe(true)
64 expect(FacebookOptionSchema.parse({
65 isReel: true,
66 page_id: 'page-id',
67 contentCategory: 'reel',
68 publishTarget: 'reel',
69 videoContentCategory: 'reel',
70 })).toEqual({})
71 })
72
73 it('validates Threads reply controls by official values', () => {
74 expect(ThreadsOptionSchema.safeParse({ reply_control: ThreadsReplyControl.MentionedOnly }).success).toBe(true)
75 expect(ThreadsOptionSchema.safeParse({ reply_control: 'followers' }).success).toBe(false)
76 expect(ThreadsOptionSchema.safeParse({ link_attachment: 'https://example.test/post' }).success).toBe(true)
77 expect(ThreadsOptionSchema.parse({
78 link_attachment_url: 'https://example.test/post',
79 topic_tag: 'ai',
80 })).toEqual({})
81 })
82
83 it('supports only user-controlled Douyin options', () => {
84 expect(DouyinOptionSchema.safeParse({
85 short_title: '短标题',
86 download_type: DouyinDownloadType.Allow,
87 private_status: DouyinPrivateStatus.Public,
88 }).success).toBe(true)
89 expect(DouyinOptionSchema.parse({
90 shareId: 'share_1',
91 title: '标题',
92 hashtag_list: ['topic'],
93 title_hashtag_list: [{ name: 'topic', start: 0 }],
94 video_path: 'https://cdn.example.com/video.mp4',
95 image_list_path: ['https://cdn.example.com/image.jpg'],
96 custom_cover_image_url: 'https://cdn.example.com/cover.jpg',
97 download_type: DouyinDownloadType.Allow,
98 })).toEqual({
99 download_type: DouyinDownloadType.Allow,
100 })
101 })
102
103 it('keeps TikTok privacy optional because creator_info owns the allowed values', () => {
104 expect(TiktokOptionSchema.safeParse({}).success).toBe(true)
105 expect(TiktokOptionSchema.safeParse({ privacy_level: TikTokPrivacyLevel.Public }).success).toBe(true)
106 expect(TiktokOptionSchema.safeParse({ privacy_level: 'PUBLIC' }).success).toBe(false)
107 })
108
109 it('enforces X poll option count and text length', () => {
110 expect(TwitterOptionSchema.safeParse({
111 poll: {
112 options: ['yes', 'no'],
113 duration_minutes: 60,
114 },
115 }).success).toBe(true)
116 expect(TwitterOptionSchema.safeParse({
117 poll: {
118 options: ['a'.repeat(26), 'no'],
119 duration_minutes: 60,
120 },
121 }).success).toBe(false)
122 })
123
124 it('strips option fields that belong to another platform', () => {
125 const result = PlatformPublishOptionItemSchema.safeParse({
126 platform: AccountType.YouTube,
127 option: { tid: 21 },
128 })
129
130 expect(result.success).toBe(true)
131 if (result.success) {
132 expect(result.data.option).not.toHaveProperty('tid')
133 }
134 })
135
136 it('keeps platform-specific required option rules at DTO boundary', () => {
137 expect(PlatformPublishOptionItemSchema.safeParse({
138 platform: AccountType.Bilibili,
139 }).success).toBe(false)
140
141 expect(PlatformPublishOptionItemSchema.safeParse({
142 platform: AccountType.GoogleBusiness,
143 option: {},
144 }).success).toBe(false)
145 })
146
147 it('formats validation messages by locale and keeps issue structure', () => {
148 const issue = {
149 code: PublishValidationIssueCode.TooBig,
150 path: ['content', 'body'],
151 params: {
152 field: PublishValidationField.Body,
153 maximum: 280,
154 unit: 'characters',
155 },
156 }
157
158 expect(formatPublishValidationIssue(issue, 'en-US')).toEqual({
159 ...issue,
160 message: 'Body must be at most 280 characters',
161 })
162 expect(formatPublishValidationIssue(issue, 'zh-CN')).toEqual({
163 ...issue,
164 message: '正文不能超过 280 个字符',
165 })
166 })
167
168 it('formats duration validation messages by available bounds', () => {
169 const issue = {
170 code: PublishValidationIssueCode.InvalidDuration,
171 path: ['content', 'media', 0],
172 params: {
173 field: PublishValidationField.Video,
174 minimum: 3,
175 maximum: 90,
176 },
177 }
178
179 expect(formatPublishValidationIssue(issue, 'en-US')).toEqual({
180 ...issue,
181 message: 'Video duration must be between 3 and 90 seconds',
182 })
183 expect(formatPublishValidationIssue(issue, 'zh-CN')).toEqual({
184 ...issue,
185 message: '视频时长必须在 3 到 90 秒之间',
186 })
187
188 expect(formatPublishValidationIssue({
189 ...issue,
190 params: { field: PublishValidationField.Video, minimum: 3 },
191 }, 'zh-CN').message).toBe('视频时长不能少于 3 秒')
192 expect(formatPublishValidationIssue({
193 ...issue,
194 params: { field: PublishValidationField.Video, maximum: 90 },
195 }, 'en-US').message).toBe('Video duration must be at most 90 seconds')
196 })
197
198 it('formats aspect ratio validation messages with dimension and current value', () => {
199 const issue = {
200 code: PublishValidationIssueCode.TooSmall,
201 path: ['content', 'media', 0],
202 params: {
203 field: PublishValidationField.Image,
204 dimension: 'aspectRatio',
205 current: 0.56,
206 minimum: 0.8,
207 },
208 }
209
210 expect(formatPublishValidationIssue(issue, 'zh-CN').message).toBe('图片宽高比不能少于 0.8,当前为 0.56')
211 expect(formatPublishValidationIssue(issue, 'en-US').message).toBe('Image aspect ratio must be at least 0.8, current is 0.56')
212 expect(formatPublishValidationIssue({
213 ...issue,
214 code: PublishValidationIssueCode.TooBig,
215 params: {
216 field: PublishValidationField.Image,
217 dimension: 'aspectRatio',
218 current: 2,
219 maximum: 1.91,
220 },
221 }, 'zh-CN').message).toBe('图片宽高比不能超过 1.91,当前为 2')
222 })
223
224 it('parses topics from body once and strips topic tokens from text', () => {
225 expect(parseTopicsFromBody('正文 #话题 @natgeo #topic #话题')).toEqual(['话题', 'topic'])
226 expect(stripTopicsFromBody('正文 #话题\n\n#topic @natgeo')).toBe('正文\n\n@natgeo')
227 })
228
229 it('maps body topics to their insertion positions after stripping topics', () => {
230 expect(parseTopicInsertionsFromBody('测试 #重庆 标题 #北京')).toEqual([
231 { name: '重庆', start: 2 },
232 { name: '北京', start: 5 },
233 ])
234 })
235 })
236
237 function getJsonSchemaProperty(schema: z.ZodTypeAny, parent: string, child: string): { description?: string } {
238 const jsonSchema = z.toJSONSchema(schema) as {
239 properties?: Record<string, {
240 properties?: Record<string, { description?: string }>
241 items?: {
242 properties?: Record<string, { description?: string }>
243 }
244 }>
245 }
246 const parentSchema = jsonSchema.properties?.[parent]
247 const childSchema = parentSchema?.items?.properties?.[child] ?? parentSchema?.properties?.[child]
248 expect(childSchema).toBeDefined()
249 return childSchema!
250 }
251
252 function getJsonSchemaRootProperty(schema: z.ZodTypeAny, property: string): { description?: string } {
253 const jsonSchema = z.toJSONSchema(schema) as {
254 properties?: Record<string, { description?: string }>
255 }
256 const propertySchema = jsonSchema.properties?.[property]
257 expect(propertySchema).toBeDefined()
258 return propertySchema!
259 }
260
260 lines TYPESCRIPT