beervid-app-cli 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,689 @@
1
+ # 测试指南
2
+
3
+ 本文档提供 BEERVID Open API 集成的测试策略和最佳实践。
4
+
5
+ ## 目录
6
+
7
+ 1. [测试策略](#测试策略)
8
+ 2. [单元测试](#单元测试)
9
+ 3. [集成测试](#集成测试)
10
+ 4. [端到端测试](#端到端测试)
11
+ 5. [Mock 数据](#mock-数据)
12
+ 6. [测试环境配置](#测试环境配置)
13
+ 7. [CI/CD 集成](#cicd-集成)
14
+
15
+ ---
16
+
17
+ ## 测试策略
18
+
19
+ ### 测试金字塔
20
+
21
+ ```
22
+ /\
23
+ /E2E\ 少量端到端测试
24
+ /------\
25
+ / 集成 \ 适量集成测试
26
+ /----------\
27
+ / 单元测试 \ 大量单元测试
28
+ /--------------\
29
+ ```
30
+
31
+ ### 测试覆盖目标
32
+
33
+ | 层级 | 覆盖率目标 | 重点 |
34
+ |------|-----------|------|
35
+ | 单元测试 | > 80% | 业务逻辑、工具函数 |
36
+ | 集成测试 | > 60% | API 调用、数据流 |
37
+ | 端到端测试 | 核心流程 | 关键用户路径 |
38
+
39
+ ---
40
+
41
+ ## 单元测试
42
+
43
+ ### 1. 测试工具函数
44
+
45
+ ```typescript
46
+ // src/utils/validation.ts
47
+ export function validateBusinessId(id: string): boolean {
48
+ return /^[a-zA-Z0-9_-]+$/.test(id) && id.length > 0
49
+ }
50
+
51
+ export function truncateProductTitle(title: string, maxLength = 30): string {
52
+ return title.length > maxLength ? title.slice(0, maxLength) : title
53
+ }
54
+ ```
55
+
56
+ ```typescript
57
+ // tests/utils/validation.test.ts
58
+ import { describe, it, expect } from 'vitest'
59
+ import { validateBusinessId, truncateProductTitle } from '@/utils/validation'
60
+
61
+ describe('validateBusinessId', () => {
62
+ it('should accept valid business IDs', () => {
63
+ expect(validateBusinessId('biz_123')).toBe(true)
64
+ expect(validateBusinessId('7281234567890')).toBe(true)
65
+ })
66
+
67
+ it('should reject invalid business IDs', () => {
68
+ expect(validateBusinessId('')).toBe(false)
69
+ expect(validateBusinessId('biz@123')).toBe(false)
70
+ expect(validateBusinessId('biz 123')).toBe(false)
71
+ })
72
+ })
73
+
74
+ describe('truncateProductTitle', () => {
75
+ it('should not truncate short titles', () => {
76
+ expect(truncateProductTitle('Short Title')).toBe('Short Title')
77
+ })
78
+
79
+ it('should truncate long titles', () => {
80
+ const longTitle = 'This is a very long product title that exceeds the limit'
81
+ expect(truncateProductTitle(longTitle)).toHaveLength(30)
82
+ expect(truncateProductTitle(longTitle)).toBe(longTitle.slice(0, 30))
83
+ })
84
+
85
+ it('should respect custom max length', () => {
86
+ expect(truncateProductTitle('Hello World', 5)).toBe('Hello')
87
+ })
88
+ })
89
+ ```
90
+
91
+ ### 2. 测试 API 客户端
92
+
93
+ ```typescript
94
+ // tests/client/api.test.ts
95
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
96
+ import { openApiGet, openApiPost } from '@/client'
97
+
98
+ // Mock fetch
99
+ global.fetch = vi.fn()
100
+
101
+ describe('openApiGet', () => {
102
+ beforeEach(() => {
103
+ vi.clearAllMocks()
104
+ })
105
+
106
+ it('should make GET request with correct headers', async () => {
107
+ const mockResponse = {
108
+ code: 0,
109
+ success: true,
110
+ data: { url: 'https://example.com' }
111
+ }
112
+
113
+ ;(fetch as any).mockResolvedValueOnce({
114
+ ok: true,
115
+ json: async () => mockResponse
116
+ })
117
+
118
+ const result = await openApiGet('/api/v1/open/test')
119
+
120
+ expect(fetch).toHaveBeenCalledWith(
121
+ expect.stringContaining('/api/v1/open/test'),
122
+ expect.objectContaining({
123
+ method: 'GET',
124
+ headers: expect.objectContaining({
125
+ 'X-API-KEY': expect.any(String)
126
+ })
127
+ })
128
+ )
129
+
130
+ expect(result).toEqual(mockResponse.data)
131
+ })
132
+
133
+ it('should throw error on API failure', async () => {
134
+ ;(fetch as any).mockResolvedValueOnce({
135
+ ok: true,
136
+ json: async () => ({
137
+ code: 400,
138
+ success: false,
139
+ message: 'Bad Request'
140
+ })
141
+ })
142
+
143
+ await expect(openApiGet('/api/v1/open/test')).rejects.toThrow('Bad Request')
144
+ })
145
+ })
146
+ ```
147
+
148
+ ### 3. 测试业务逻辑
149
+
150
+ ```typescript
151
+ // tests/workflows/tt-publish.test.ts
152
+ import { describe, it, expect, vi } from 'vitest'
153
+ import { publishTTFlow } from '@/workflows'
154
+
155
+ vi.mock('@/client', () => ({
156
+ getUploadToken: vi.fn().mockResolvedValue('token_123'),
157
+ uploadVideo: vi.fn().mockResolvedValue('https://cdn.beervid.ai/video.mp4'),
158
+ publishVideo: vi.fn().mockResolvedValue('share_abc'),
159
+ pollStatus: vi.fn().mockResolvedValue({
160
+ status: 'PUBLISH_COMPLETE',
161
+ post_ids: ['7123456789012345678']
162
+ }),
163
+ queryVideo: vi.fn().mockResolvedValue({
164
+ playCount: 100,
165
+ likeCount: 10
166
+ })
167
+ }))
168
+
169
+ describe('publishTTFlow', () => {
170
+ it('should complete full TT publish flow', async () => {
171
+ const result = await publishTTFlow({
172
+ businessId: 'biz_123',
173
+ file: new File([''], 'video.mp4'),
174
+ caption: 'Test video'
175
+ })
176
+
177
+ expect(result).toMatchObject({
178
+ success: true,
179
+ videoId: '7123456789012345678',
180
+ stats: expect.objectContaining({
181
+ playCount: 100,
182
+ likeCount: 10
183
+ })
184
+ })
185
+ })
186
+ })
187
+ ```
188
+
189
+ ---
190
+
191
+ ## 集成测试
192
+
193
+ ### 1. 测试 API 集成
194
+
195
+ ```typescript
196
+ // tests/integration/api.test.ts
197
+ import { describe, it, expect, beforeAll } from 'vitest'
198
+ import { openApiGet } from '@/client'
199
+
200
+ // 使用测试环境的真实 API
201
+ describe('API Integration', () => {
202
+ beforeAll(() => {
203
+ // 确保使用测试环境配置
204
+ process.env.BEERVID_APP_KEY = process.env.TEST_API_KEY
205
+ process.env.BEERVID_APP_BASE_URL = 'https://test.beervid.ai'
206
+ })
207
+
208
+ it('should get TT OAuth URL', async () => {
209
+ const url = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
210
+
211
+ expect(url).toMatch(/^https:\/\/www\.tiktok\.com/)
212
+ expect(url).toContain('client_key')
213
+ })
214
+
215
+ it('should handle invalid API key', async () => {
216
+ process.env.BEERVID_APP_KEY = 'invalid_key'
217
+
218
+ await expect(
219
+ openApiGet('/api/v1/open/thirdparty-auth/tt-url')
220
+ ).rejects.toThrow(/401|Unauthorized/)
221
+ })
222
+ })
223
+ ```
224
+
225
+ ### 2. 测试数据库集成
226
+
227
+ ```typescript
228
+ // tests/integration/database.test.ts
229
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
230
+ import { db } from '@/lib/db'
231
+
232
+ describe('Database Integration', () => {
233
+ beforeEach(async () => {
234
+ // 清理测试数据
235
+ await db.accounts.deleteMany({ where: { userId: 'test_user' } })
236
+ })
237
+
238
+ afterEach(async () => {
239
+ // 清理测试数据
240
+ await db.accounts.deleteMany({ where: { userId: 'test_user' } })
241
+ })
242
+
243
+ it('should save and retrieve account', async () => {
244
+ const account = await db.accounts.create({
245
+ data: {
246
+ userId: 'test_user',
247
+ accountType: 'TT',
248
+ accountId: 'biz_123',
249
+ username: 'test_account'
250
+ }
251
+ })
252
+
253
+ expect(account.id).toBeDefined()
254
+
255
+ const retrieved = await db.accounts.findUnique({
256
+ where: { id: account.id }
257
+ })
258
+
259
+ expect(retrieved).toMatchObject({
260
+ userId: 'test_user',
261
+ accountType: 'TT',
262
+ accountId: 'biz_123'
263
+ })
264
+ })
265
+ })
266
+ ```
267
+
268
+ ---
269
+
270
+ ## 端到端测试
271
+
272
+ ### 1. 使用 Playwright
273
+
274
+ ```typescript
275
+ // tests/e2e/publish-flow.spec.ts
276
+ import { test, expect } from '@playwright/test'
277
+
278
+ test.describe('TT Publish Flow', () => {
279
+ test('should complete full publish flow', async ({ page }) => {
280
+ // 登录
281
+ await page.goto('/login')
282
+ await page.fill('[name="email"]', 'test@example.com')
283
+ await page.fill('[name="password"]', 'password')
284
+ await page.click('button[type="submit"]')
285
+
286
+ // 等待跳转到仪表板
287
+ await expect(page).toHaveURL('/dashboard')
288
+
289
+ // 点击发布按钮
290
+ await page.click('text=发布视频')
291
+
292
+ // 上传视频
293
+ await page.setInputFiles('input[type="file"]', 'tests/fixtures/video.mp4')
294
+
295
+ // 填写标题
296
+ await page.fill('[name="caption"]', 'Test video from E2E')
297
+
298
+ // 提交发布
299
+ await page.click('button:has-text("发布")')
300
+
301
+ // 等待发布完成
302
+ await expect(page.locator('text=发布成功')).toBeVisible({ timeout: 60000 })
303
+
304
+ // 验证视频出现在列表中
305
+ await page.goto('/videos')
306
+ await expect(page.locator('text=Test video from E2E')).toBeVisible()
307
+ })
308
+ })
309
+ ```
310
+
311
+ ### 2. CLI 端到端测试
312
+
313
+ ```typescript
314
+ // tests/e2e/cli.test.ts
315
+ import { describe, it, expect } from 'vitest'
316
+ import { exec } from 'child_process'
317
+ import { promisify } from 'util'
318
+
319
+ const execAsync = promisify(exec)
320
+
321
+ describe('CLI E2E', () => {
322
+ it('should show help', async () => {
323
+ const { stdout } = await execAsync('beervid --help')
324
+ expect(stdout).toContain('beervid')
325
+ expect(stdout).toContain('config')
326
+ expect(stdout).toContain('upload')
327
+ })
328
+
329
+ it('should get OAuth URL', async () => {
330
+ const { stdout } = await execAsync('beervid get-oauth-url --type tt')
331
+ const result = JSON.parse(stdout)
332
+ expect(result.url).toMatch(/^https:\/\/www\.tiktok\.com/)
333
+ })
334
+ })
335
+ ```
336
+
337
+ ---
338
+
339
+ ## Mock 数据
340
+
341
+ ### 1. Mock API 响应
342
+
343
+ ```typescript
344
+ // tests/mocks/api-responses.ts
345
+ export const mockOAuthUrl = {
346
+ code: 0,
347
+ success: true,
348
+ message: 'ok',
349
+ data: 'https://www.tiktok.com/v2/auth/authorize?client_key=test'
350
+ }
351
+
352
+ export const mockAccountInfo = {
353
+ code: 0,
354
+ success: true,
355
+ data: {
356
+ accountType: 'TT',
357
+ accountId: '7281234567890',
358
+ username: 'test_user',
359
+ displayName: 'Test User',
360
+ profileUrl: 'https://example.com/avatar.jpg',
361
+ followersCount: 1000
362
+ }
363
+ }
364
+
365
+ export const mockUploadToken = {
366
+ code: 0,
367
+ success: true,
368
+ data: {
369
+ uploadToken: 'token_abc123',
370
+ uploadUrl: 'https://upload.beervid.ai'
371
+ }
372
+ }
373
+
374
+ export const mockPublishResponse = {
375
+ code: 0,
376
+ success: true,
377
+ data: {
378
+ shareId: 'share_abc123'
379
+ }
380
+ }
381
+
382
+ export const mockPollStatus = {
383
+ code: 0,
384
+ success: true,
385
+ data: {
386
+ status: 'PUBLISH_COMPLETE',
387
+ post_ids: ['7123456789012345678']
388
+ }
389
+ }
390
+
391
+ export const mockVideoStats = {
392
+ code: 0,
393
+ success: true,
394
+ data: {
395
+ videos: [{
396
+ itemId: '7123456789012345678',
397
+ playCount: 1000,
398
+ likeCount: 100,
399
+ commentCount: 10,
400
+ shareCount: 5,
401
+ shareUrl: 'https://www.tiktok.com/@user/video/7123456789012345678'
402
+ }]
403
+ }
404
+ }
405
+ ```
406
+
407
+ ### 2. Mock 服务器(MSW)
408
+
409
+ ```typescript
410
+ // tests/mocks/server.ts
411
+ import { setupServer } from 'msw/node'
412
+ import { rest } from 'msw'
413
+ import * as responses from './api-responses'
414
+
415
+ export const server = setupServer(
416
+ rest.get('https://open.beervid.ai/api/v1/open/thirdparty-auth/tt-url', (req, res, ctx) => {
417
+ return res(ctx.json(responses.mockOAuthUrl))
418
+ }),
419
+
420
+ rest.post('https://open.beervid.ai/api/v1/open/account/info', (req, res, ctx) => {
421
+ return res(ctx.json(responses.mockAccountInfo))
422
+ }),
423
+
424
+ rest.post('https://open.beervid.ai/api/v1/open/upload-token/generate', (req, res, ctx) => {
425
+ return res(ctx.json(responses.mockUploadToken))
426
+ })
427
+ )
428
+
429
+ // 测试设置
430
+ beforeAll(() => server.listen())
431
+ afterEach(() => server.resetHandlers())
432
+ afterAll(() => server.close())
433
+ ```
434
+
435
+ ### 3. 使用 Mock 服务器
436
+
437
+ ```typescript
438
+ // tests/integration/with-mock.test.ts
439
+ import { describe, it, expect } from 'vitest'
440
+ import { server } from '../mocks/server'
441
+ import { openApiGet } from '@/client'
442
+
443
+ describe('API with Mock Server', () => {
444
+ it('should get OAuth URL', async () => {
445
+ const url = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
446
+ expect(url).toBe('https://www.tiktok.com/v2/auth/authorize?client_key=test')
447
+ })
448
+ })
449
+ ```
450
+
451
+ ---
452
+
453
+ ## 测试环境配置
454
+
455
+ ### 1. 环境变量
456
+
457
+ ```bash
458
+ # .env.test
459
+ BEERVID_APP_KEY=test_key_123
460
+ BEERVID_APP_BASE_URL=https://test.beervid.ai
461
+ DATABASE_URL=postgresql://test:test@localhost:5432/beervid_test
462
+ ```
463
+
464
+ ### 2. Vitest 配置
465
+
466
+ ```typescript
467
+ // vitest.config.ts
468
+ import { defineConfig } from 'vitest/config'
469
+ import path from 'path'
470
+
471
+ export default defineConfig({
472
+ test: {
473
+ globals: true,
474
+ environment: 'node',
475
+ setupFiles: ['./tests/setup.ts'],
476
+ coverage: {
477
+ provider: 'v8',
478
+ reporter: ['text', 'json', 'html'],
479
+ exclude: [
480
+ 'node_modules/',
481
+ 'tests/',
482
+ '**/*.test.ts',
483
+ '**/*.spec.ts'
484
+ ]
485
+ }
486
+ },
487
+ resolve: {
488
+ alias: {
489
+ '@': path.resolve(__dirname, './src')
490
+ }
491
+ }
492
+ })
493
+ ```
494
+
495
+ ### 3. 测试设置文件
496
+
497
+ ```typescript
498
+ // tests/setup.ts
499
+ import { beforeAll, afterAll, afterEach } from 'vitest'
500
+ import { server } from './mocks/server'
501
+ import dotenv from 'dotenv'
502
+
503
+ // 加载测试环境变量
504
+ dotenv.config({ path: '.env.test' })
505
+
506
+ // 设置 Mock 服务器
507
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
508
+ afterEach(() => server.resetHandlers())
509
+ afterAll(() => server.close())
510
+
511
+ // 全局测试超时
512
+ vi.setConfig({ testTimeout: 10000 })
513
+ ```
514
+
515
+ ---
516
+
517
+ ## CI/CD 集成
518
+
519
+ ### 1. GitHub Actions
520
+
521
+ ```yaml
522
+ # .github/workflows/test.yml
523
+ name: Test
524
+
525
+ on:
526
+ push:
527
+ branches: [main, develop]
528
+ pull_request:
529
+ branches: [main, develop]
530
+
531
+ jobs:
532
+ test:
533
+ runs-on: ubuntu-latest
534
+
535
+ services:
536
+ postgres:
537
+ image: postgres:15
538
+ env:
539
+ POSTGRES_PASSWORD: test
540
+ POSTGRES_DB: beervid_test
541
+ options: >-
542
+ --health-cmd pg_isready
543
+ --health-interval 10s
544
+ --health-timeout 5s
545
+ --health-retries 5
546
+
547
+ steps:
548
+ - uses: actions/checkout@v3
549
+
550
+ - name: Setup Node.js
551
+ uses: actions/setup-node@v3
552
+ with:
553
+ node-version: '22'
554
+ cache: 'npm'
555
+
556
+ - name: Install dependencies
557
+ run: npm ci
558
+
559
+ - name: Run type check
560
+ run: npm run typecheck
561
+
562
+ - name: Run tests
563
+ env:
564
+ BEERVID_APP_KEY: ${{ secrets.TEST_API_KEY }}
565
+ DATABASE_URL: postgresql://postgres:test@localhost:5432/beervid_test
566
+ run: npm test -- --coverage
567
+
568
+ - name: Upload coverage
569
+ uses: codecov/codecov-action@v3
570
+ with:
571
+ files: ./coverage/coverage-final.json
572
+ ```
573
+
574
+ ### 2. 测试脚本
575
+
576
+ ```json
577
+ // package.json
578
+ {
579
+ "scripts": {
580
+ "test": "vitest run",
581
+ "test:watch": "vitest",
582
+ "test:coverage": "vitest run --coverage",
583
+ "test:unit": "vitest run tests/unit",
584
+ "test:integration": "vitest run tests/integration",
585
+ "test:e2e": "playwright test"
586
+ }
587
+ }
588
+ ```
589
+
590
+ ---
591
+
592
+ ## 测试最佳实践
593
+
594
+ ### 1. 遵循 AAA 模式
595
+
596
+ ```typescript
597
+ it('should truncate long product titles', () => {
598
+ // Arrange(准备)
599
+ const longTitle = 'This is a very long product title'
600
+
601
+ // Act(执行)
602
+ const result = truncateProductTitle(longTitle, 10)
603
+
604
+ // Assert(断言)
605
+ expect(result).toBe('This is a ')
606
+ expect(result).toHaveLength(10)
607
+ })
608
+ ```
609
+
610
+ ### 2. 使用描述性测试名称
611
+
612
+ ```typescript
613
+ // ❌ 不好
614
+ it('test 1', () => { ... })
615
+
616
+ // ✅ 好
617
+ it('should return 401 when API key is invalid', () => { ... })
618
+ ```
619
+
620
+ ### 3. 测试边界情况
621
+
622
+ ```typescript
623
+ describe('validateBusinessId', () => {
624
+ it('should handle empty string', () => {
625
+ expect(validateBusinessId('')).toBe(false)
626
+ })
627
+
628
+ it('should handle very long IDs', () => {
629
+ const longId = 'a'.repeat(1000)
630
+ expect(validateBusinessId(longId)).toBe(true)
631
+ })
632
+
633
+ it('should handle special characters', () => {
634
+ expect(validateBusinessId('biz@123')).toBe(false)
635
+ expect(validateBusinessId('biz_123')).toBe(true)
636
+ expect(validateBusinessId('biz-123')).toBe(true)
637
+ })
638
+ })
639
+ ```
640
+
641
+ ### 4. 避免测试实现细节
642
+
643
+ ```typescript
644
+ // ❌ 测试实现细节
645
+ it('should call fetch with correct URL', () => {
646
+ expect(fetch).toHaveBeenCalledWith('https://...')
647
+ })
648
+
649
+ // ✅ 测试行为
650
+ it('should return OAuth URL', async () => {
651
+ const url = await getOAuthUrl('tt')
652
+ expect(url).toMatch(/^https:\/\/www\.tiktok\.com/)
653
+ })
654
+ ```
655
+
656
+ ### 5. 保持测试独立
657
+
658
+ ```typescript
659
+ // ❌ 测试相互依赖
660
+ let sharedState: any
661
+
662
+ it('test 1', () => {
663
+ sharedState = { value: 1 }
664
+ })
665
+
666
+ it('test 2', () => {
667
+ expect(sharedState.value).toBe(1) // 依赖 test 1
668
+ })
669
+
670
+ // ✅ 测试独立
671
+ it('test 1', () => {
672
+ const state = { value: 1 }
673
+ expect(state.value).toBe(1)
674
+ })
675
+
676
+ it('test 2', () => {
677
+ const state = { value: 1 }
678
+ expect(state.value).toBe(1)
679
+ })
680
+ ```
681
+
682
+ ---
683
+
684
+ ## 相关文档
685
+
686
+ - [故障排查指南](./troubleshooting.md)
687
+ - [安全最佳实践](./security-best-practices.md)
688
+ - [性能与限流](./performance-and-limits.md)
689
+ - [示例工程](../example/)