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