chore: 初始化TikTok短剧后端项目基础架构

创建了完整的NestJS项目结构,包含配置文件、核心模块、工具类、拦截器、过滤器等基础代码,实现了健康检查、用户认证、管理员RBAC、剧集管理、订单支付、媒体管理等核心功能的基础框架,配置了ESLint、TypeScript、Jest等开发工具链。
This commit is contained in:
2026-07-01 13:55:37 +08:00
commit ef8c31d3be
92 changed files with 11462 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers';
describe('Admin permissions and user management', () => {
let app: INestApplication;
let superAdminToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrapApp(app);
await app.init();
superAdminToken = await loginAdmin(app);
});
afterAll(async () => {
await app.close();
});
it('requires admin authentication for admin APIs', async () => {
const response = await request(app.getHttpServer())
.get('/api/admin/v1/users')
.expect(401);
expect(response.body).toMatchObject({
code: 401,
data: {},
message: 'Admin not authenticated',
});
});
it('creates permissions, roles, admin users, and enforces permission checks', async () => {
const permissions = await request(app.getHttpServer())
.get('/api/admin/v1/permissions')
.set('Authorization', `Bearer ${superAdminToken}`)
.expect(200);
expect(permissions.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'permission:read',
name: '查看权限',
}),
expect.objectContaining({
code: 'permission:create',
name: '创建权限',
}),
]),
);
const dramaReadPermission = permissions.body.data.list.find(
(permission: { code: string }) => permission.code === 'drama:read',
);
const role = await request(app.getHttpServer())
.post('/api/admin/v1/roles')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
code: 'drama_viewer',
name: 'Drama Viewer',
description: 'Can only view dramas',
permissionIds: [dramaReadPermission.id],
})
.expect(201);
const admin = await request(app.getHttpServer())
.post('/api/admin/v1/admin-users')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
username: 'viewer',
password: 'viewer123',
nickname: 'Viewer',
email: 'viewer@example.com',
roleIds: [role.body.data.id],
})
.expect(201);
expect(admin.body.data).toMatchObject({
username: 'viewer',
nickname: 'Viewer',
email: 'viewer@example.com',
status: 'active',
roles: [expect.objectContaining({ code: 'drama_viewer' })],
});
expect(admin.body.data.passwordHash).toBeUndefined();
const viewerLogin = await request(app.getHttpServer())
.post('/api/admin/v1/auth/login')
.send({ username: 'viewer', password: 'viewer123' })
.expect(201);
await request(app.getHttpServer())
.get('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${viewerLogin.body.data.accessToken}`)
.expect(200);
const forbidden = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${viewerLogin.body.data.accessToken}`)
.send({
title: 'Forbidden Create',
coverUrl: 'https://cdn.example.com/forbidden.jpg',
type: 'test',
status: 'draft',
})
.expect(403);
expect(forbidden.body).toMatchObject({
code: 403,
data: {},
message: 'No permission',
});
});
it('updates role permissions from the role permission assignment endpoint', async () => {
const permissions = await request(app.getHttpServer())
.get('/api/admin/v1/permissions')
.set('Authorization', `Bearer ${superAdminToken}`)
.expect(200);
const dramaReadPermission = permissions.body.data.list.find(
(permission: { code: string }) => permission.code === 'drama:read',
);
const role = await request(app.getHttpServer())
.post('/api/admin/v1/roles')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
code: 'assignable_role',
name: 'Assignable Role',
description: 'Role permissions can be assigned later',
permissionIds: [],
})
.expect(201);
const updatedRole = await request(app.getHttpServer())
.patch(`/api/admin/v1/roles/${role.body.data.id}/permissions`)
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ permissionIds: [dramaReadPermission.id] })
.expect(200);
expect(updatedRole.body.data).toMatchObject({
id: role.body.data.id,
permissionIds: [dramaReadPermission.id],
permissions: [expect.objectContaining({ code: 'drama:read' })],
});
});
it('creates roles with assigned permissions and rejects duplicate role codes', async () => {
const permissions = await request(app.getHttpServer())
.get('/api/admin/v1/permissions')
.set('Authorization', `Bearer ${superAdminToken}`)
.expect(200);
const roleReadPermission = permissions.body.data.list.find(
(permission: { code: string }) => permission.code === 'role:read',
);
const roleCreatePermission = permissions.body.data.list.find(
(permission: { code: string }) => permission.code === 'role:create',
);
const createdRole = await request(app.getHttpServer())
.post('/api/admin/v1/roles')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
code: 'system_operator',
name: 'System Operator',
description: 'Can manage system roles',
permissionIds: [roleReadPermission.id, roleCreatePermission.id],
})
.expect(201);
expect(createdRole.body.data).toMatchObject({
code: 'system_operator',
name: 'System Operator',
permissionIds: [roleReadPermission.id, roleCreatePermission.id],
permissions: [
expect.objectContaining({ code: 'role:read' }),
expect.objectContaining({ code: 'role:create' }),
],
});
const duplicate = await request(app.getHttpServer())
.post('/api/admin/v1/roles')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
code: 'system_operator',
name: 'Duplicated System Operator',
})
.expect(409);
expect(duplicate.body).toMatchObject({
code: 409,
data: {},
message: 'Role code already exists',
});
});
it('allows the current admin to change password with the old password', async () => {
const admin = await request(app.getHttpServer())
.post('/api/admin/v1/admin-users')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
username: 'password-user',
password: 'oldpass123',
nickname: 'Password User',
email: 'password-user@example.com',
})
.expect(201);
expect(admin.body.data.passwordHash).toBeUndefined();
const login = await request(app.getHttpServer())
.post('/api/admin/v1/auth/login')
.send({ username: 'password-user', password: 'oldpass123' })
.expect(201);
await request(app.getHttpServer())
.patch('/api/admin/v1/auth/password')
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
.send({ oldPassword: 'wrongpass', newPassword: 'newpass123' })
.expect(422);
const changed = await request(app.getHttpServer())
.patch('/api/admin/v1/auth/password')
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
.send({ oldPassword: 'oldpass123', newPassword: 'newpass123' })
.expect(200);
expect(changed.body.data).toEqual({});
await request(app.getHttpServer())
.post('/api/admin/v1/auth/login')
.send({ username: 'password-user', password: 'oldpass123' })
.expect(401);
await request(app.getHttpServer())
.post('/api/admin/v1/auth/login')
.send({ username: 'password-user', password: 'newpass123' })
.expect(201);
});
it('allows the current admin to view and update profile information', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/admin-users')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
username: 'profile-user',
password: 'profile123',
nickname: 'Profile User',
email: 'profile-user@example.com',
})
.expect(201);
const login = await request(app.getHttpServer())
.post('/api/admin/v1/auth/login')
.send({ username: 'profile-user', password: 'profile123' })
.expect(201);
const updated = await request(app.getHttpServer())
.patch('/api/admin/v1/auth/profile')
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
.send({
nickname: 'Updated Profile User',
email: 'updated-profile-user@example.com',
})
.expect(200);
expect(updated.body.data).toMatchObject({
username: 'profile-user',
nickname: 'Updated Profile User',
email: 'updated-profile-user@example.com',
});
expect(updated.body.data.passwordHash).toBeUndefined();
const profile = await request(app.getHttpServer())
.get('/api/admin/v1/auth/profile')
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
.expect(200);
expect(profile.body.data).toMatchObject({
username: 'profile-user',
nickname: 'Updated Profile User',
email: 'updated-profile-user@example.com',
});
});
it('assigns roles to existing admin users', async () => {
const permissions = await request(app.getHttpServer())
.get('/api/admin/v1/permissions')
.set('Authorization', `Bearer ${superAdminToken}`)
.expect(200);
const userReadPermission = permissions.body.data.list.find(
(permission: { code: string }) => permission.code === 'user:read',
);
const role = await request(app.getHttpServer())
.post('/api/admin/v1/roles')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
code: 'user_reader',
name: 'User Reader',
permissionIds: [userReadPermission.id],
})
.expect(201);
const admin = await request(app.getHttpServer())
.post('/api/admin/v1/admin-users')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
username: 'role-assignee',
password: 'assign123',
nickname: 'Role Assignee',
email: 'role-assignee@example.com',
roleIds: [],
})
.expect(201);
const assigned = await request(app.getHttpServer())
.patch(`/api/admin/v1/admin-users/${admin.body.data.id}/roles`)
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ roleIds: [role.body.data.id] })
.expect(200);
expect(assigned.body.data).toMatchObject({
id: admin.body.data.id,
roles: [expect.objectContaining({ code: 'user_reader' })],
});
});
it('lists and updates app users from admin APIs', async () => {
await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'user-management-code',
mockOpenId: 'open-user-management',
nickname: 'Managed User',
})
.expect(201);
const users = await request(app.getHttpServer())
.get('/api/admin/v1/users')
.set('Authorization', `Bearer ${superAdminToken}`)
.expect(200);
expect(users.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
tiktokOpenId: 'open-user-management',
nickname: 'Managed User',
status: 'active',
}),
]),
);
const managedUser = users.body.data.list.find(
(user: { tiktokOpenId: string }) =>
user.tiktokOpenId === 'open-user-management',
);
const disabled = await request(app.getHttpServer())
.patch(`/api/admin/v1/users/${managedUser.id}/status`)
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ status: 'disabled' })
.expect(200);
expect(disabled.body.data).toMatchObject({
id: managedUser.id,
status: 'disabled',
});
});
});

View File

@@ -0,0 +1,111 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { validate as validateUuid, version as uuidVersion } from 'uuid';
import { AppModule } from '../src/app.module';
import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers';
describe('API standard', () => {
let app: INestApplication;
let adminToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrapApp(app);
await app.init();
adminToken = await loginAdmin(app);
});
afterAll(async () => {
await app.close();
});
it('wraps successful responses with code, data, message, timestamp, requestId and cost', async () => {
const response = await request(app.getHttpServer())
.get('/api/app/v1/health')
.expect(200);
expect(response.body).toEqual({
code: 0,
data: { status: 'ok' },
message: 'success',
timestamp: expect.any(Number),
requestId: expect.any(String),
cost: expect.stringMatching(/^\d+ms$/),
});
expect(validateUuid(response.body.requestId)).toBe(true);
expect(uuidVersion(response.body.requestId)).toBe(4);
});
it('reuses a valid X-Request-Id header', async () => {
const requestId = '0f5c2e16-1e44-4d55-93a4-ef7c66d9d1f2';
const response = await request(app.getHttpServer())
.get('/api/app/v1/health')
.set('X-Request-Id', requestId)
.expect(200);
expect(response.body.requestId).toBe(requestId);
});
it('wraps errors in the standard response shape', async () => {
const response = await request(app.getHttpServer())
.get('/api/app/v1/missing')
.expect(404);
expect(response.body).toEqual({
code: 404,
data: {},
message: expect.any(String),
timestamp: expect.any(Number),
requestId: expect.any(String),
cost: expect.stringMatching(/^\d+ms$/),
});
});
it('returns paginated lists using data.list and data.pagination', async () => {
const response = await request(app.getHttpServer())
.get('/api/admin/v1/logs/requests')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(response.body.code).toBe(0);
expect(response.body.data).toEqual({
list: expect.any(Array),
pagination: {
page: 1,
pageSize: 20,
total: expect.any(Number),
},
});
});
it('can query a request log by requestId', async () => {
const requestId = '180facd4-68f9-48c9-b6b1-3d8eab56a681';
await request(app.getHttpServer())
.get('/api/app/v1/health')
.set('X-Request-Id', requestId)
.expect(200);
const response = await request(app.getHttpServer())
.get(`/api/admin/v1/logs/requests/${requestId}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(response.body.data).toMatchObject({
requestId,
method: 'GET',
path: '/api/app/v1/health',
statusCode: 200,
responseCode: 0,
message: 'success',
});
expect(response.body.data.costMs).toEqual(expect.any(Number));
});
});

View File

@@ -0,0 +1,237 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers';
describe('Core drama platform modules', () => {
let app: INestApplication;
let adminToken: string;
let userToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrapApp(app);
await app.init();
adminToken = await loginAdmin(app);
const login = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'core-modules-code',
mockOpenId: 'core-open-id',
nickname: 'Core User',
avatarUrl: 'https://cdn.example.com/avatar.png',
})
.expect(201);
userToken = login.body.data.accessToken;
});
afterAll(async () => {
await app.close();
});
it('creates albums and episodes with TikTok, media, review, publish, and pricing fields', async () => {
const cover = await request(app.getHttpServer())
.post('/api/admin/v1/media/covers')
.set('Authorization', `Bearer ${adminToken}`)
.send({
fileName: 'cover.jpg',
url: 'https://cdn.example.com/cover.jpg',
})
.expect(201);
const uploadJob = await request(app.getHttpServer())
.post('/api/admin/v1/media/upload-jobs')
.set('Authorization', `Bearer ${adminToken}`)
.send({
jobId: 'job-001',
byteplusVid: 'v02-byteplus-001',
status: 'completed',
rawResponse: { provider: 'byteplus' },
})
.expect(201);
expect(uploadJob.body.data).toMatchObject({
id: expect.any(Number),
jobId: 'job-001',
byteplusVid: 'v02-byteplus-001',
status: 'completed',
rawResponse: { provider: 'byteplus' },
});
const album = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
tiktokAlbumId: 'tk-album-001',
title: 'Core Album',
description: 'Album for core schema.',
coverId: cover.body.data.id,
coverUrl: cover.body.data.url,
type: 'revenge',
status: 'draft',
onlineVersion: 1,
})
.expect(201);
expect(album.body.data).toMatchObject({
id: expect.any(Number),
tiktokAlbumId: 'tk-album-001',
title: 'Core Album',
coverId: cover.body.data.id,
coverUrl: 'https://cdn.example.com/cover.jpg',
status: 'draft',
onlineVersion: 1,
});
const episode = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
albumId: album.body.data.id,
tiktokEpisodeId: 'tk-episode-001',
seq: 1,
title: 'Core Episode 1',
byteplusVid: 'v02-byteplus-001',
coverId: cover.body.data.id,
coverUrl: cover.body.data.url,
videoUrl: 'https://cdn.example.com/episode.mp4',
freeType: 'paid',
price: 299,
reviewStatus: 'pending',
publishStatus: 'draft',
durationSeconds: 300,
})
.expect(201);
expect(episode.body.data).toMatchObject({
id: expect.any(Number),
albumId: album.body.data.id,
tiktokEpisodeId: 'tk-episode-001',
seq: 1,
byteplusVid: 'v02-byteplus-001',
coverId: cover.body.data.id,
freeType: 'paid',
price: 299,
reviewStatus: 'pending',
publishStatus: 'draft',
});
});
it('supports review submission, publish status changes, player auth, trade orders, webhook unlocks, and unlock records', async () => {
const album = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Player Album',
coverUrl: 'https://cdn.example.com/player-cover.jpg',
type: 'player',
status: 'published',
onlineVersion: 1,
})
.expect(201);
const episode = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
albumId: album.body.data.id,
seq: 1,
title: 'Paid Player Episode',
byteplusVid: 'v02-player-001',
coverUrl: 'https://cdn.example.com/player-episode.jpg',
videoUrl: 'https://cdn.example.com/player-episode.mp4',
freeType: 'paid',
price: 399,
reviewStatus: 'approved',
publishStatus: 'published',
durationSeconds: 300,
})
.expect(201);
await request(app.getHttpServer())
.post(`/api/admin/v1/dramas/${album.body.data.id}/submit-review`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
await request(app.getHttpServer())
.patch(`/api/admin/v1/episodes/${episode.body.data.id}/publish-status`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ publishStatus: 'published' })
.expect(200);
const lockedPlayer = await request(app.getHttpServer())
.get(`/api/app/v1/player/episodes/${episode.body.data.id}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(lockedPlayer.body.data).toMatchObject({
albumId: album.body.data.id,
episodeId: episode.body.data.id,
vid: 'v02-player-001',
hasPermission: false,
playAuthToken: null,
});
const order = await request(app.getHttpServer())
.post('/api/app/v1/orders')
.set('Authorization', `Bearer ${userToken}`)
.send({ episodeId: episode.body.data.id })
.expect(201);
expect(order.body.data).toMatchObject({
id: expect.any(Number),
albumId: album.body.data.id,
episodeId: episode.body.data.id,
tradeOrderId: expect.stringMatching(/^TRADE/),
amount: 399,
status: 'pending',
});
await request(app.getHttpServer())
.post('/api/webhooks/tiktok/payments')
.send({
tradeOrderId: order.body.data.tradeOrderId,
tiktokOrderId: 'tk-order-001',
providerTransactionId: 'txn-core-001',
status: 'paid',
signature: 'dev-signature',
})
.expect(201);
const unlockedPlayer = await request(app.getHttpServer())
.get(`/api/app/v1/player/episodes/${episode.body.data.id}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(unlockedPlayer.body.data).toMatchObject({
albumId: album.body.data.id,
episodeId: episode.body.data.id,
vid: 'v02-player-001',
hasPermission: true,
playAuthToken: expect.stringMatching(/^play_auth_/),
});
const unlocks = await request(app.getHttpServer())
.get('/api/admin/v1/unlock-records')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(unlocks.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
userId: expect.any(Number),
episodeId: episode.body.data.id,
source: 'payment',
}),
]),
);
});
});

200
test/dramas.e2e-spec.ts Normal file
View File

@@ -0,0 +1,200 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers';
describe('Dramas and episodes', () => {
let app: INestApplication;
let adminToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrapApp(app);
await app.init();
adminToken = await loginAdmin(app);
});
afterAll(async () => {
await app.close();
});
it('allows admin APIs to create a drama and episodes with release metadata', async () => {
const dramaResponse = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'City Revenge',
description: 'A short drama series.',
coverUrl: 'https://cdn.example.com/cover.jpg',
portraitCoverUrl: 'https://cdn.example.com/portrait.jpg',
landscapeCoverUrl: 'https://cdn.example.com/landscape.jpg',
type: 'revenge',
tags: ['urban', 'revenge'],
region: 'US',
language: 'en',
year: 2026,
status: 'published',
sortOrder: 10,
isRecommended: true,
})
.expect(201);
expect(dramaResponse.body.data).toMatchObject({
id: expect.any(Number),
title: 'City Revenge',
status: 'published',
type: 'revenge',
});
const dramaId = dramaResponse.body.data.id;
const episodeResponse = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId,
episodeNo: 1,
title: 'Episode 1',
description: 'Opening episode.',
coverUrl: 'https://cdn.example.com/ep1.jpg',
videoUrl: 'https://cdn.example.com/ep1.mp4',
trialDurationSeconds: 60,
durationSeconds: 300,
isPaid: true,
unlockPrice: 199,
publishAt: '2026-07-01T00:00:00.000Z',
nextReleaseAt: '2026-07-02T00:00:00.000Z',
status: 'published',
})
.expect(201);
expect(episodeResponse.body.data).toMatchObject({
id: expect.any(Number),
dramaId,
episodeNo: 1,
nextReleaseAt: '2026-07-02T00:00:00.000Z',
});
});
it('rejects duplicate episode numbers under the same drama', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Duplicate Episode Test',
coverUrl: 'https://cdn.example.com/cover.jpg',
type: 'romance',
status: 'draft',
})
.expect(201);
const payload = {
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Episode 1',
coverUrl: 'https://cdn.example.com/ep1.jpg',
videoUrl: 'https://cdn.example.com/ep1.mp4',
durationSeconds: 300,
status: 'draft',
};
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send(payload)
.expect(201);
const duplicate = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send(payload)
.expect(409);
expect(duplicate.body).toMatchObject({
code: 409,
data: {},
message: 'Episode number already exists in this drama',
});
});
it('returns only published dramas and published episodes to app APIs', async () => {
const published = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Published Drama',
coverUrl: 'https://cdn.example.com/pub.jpg',
type: 'action',
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Draft Drama',
coverUrl: 'https://cdn.example.com/draft.jpg',
type: 'action',
status: 'draft',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: published.body.data.id,
episodeNo: 1,
title: 'Published Episode',
coverUrl: 'https://cdn.example.com/ep1.jpg',
videoUrl: 'https://cdn.example.com/ep1.mp4',
durationSeconds: 300,
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: published.body.data.id,
episodeNo: 2,
title: 'Draft Episode',
coverUrl: 'https://cdn.example.com/ep2.jpg',
videoUrl: 'https://cdn.example.com/ep2.mp4',
durationSeconds: 300,
status: 'draft',
})
.expect(201);
const dramas = await request(app.getHttpServer())
.get('/api/app/v1/dramas')
.expect(200);
expect(dramas.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Published Drama' }),
]),
);
expect(dramas.body.data.list).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]),
);
const episodes = await request(app.getHttpServer())
.get(`/api/app/v1/dramas/${published.body.data.id}/episodes`)
.expect(200);
expect(episodes.body.data.list).toEqual([
expect.objectContaining({
episodeNo: 1,
title: 'Published Episode',
}),
]);
});
});

10
test/jest-e2e.json Normal file
View File

@@ -0,0 +1,10 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"watchman": false,
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,131 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers';
describe('Auth, orders, and playback', () => {
let app: INestApplication;
let adminToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrapApp(app);
await app.init();
adminToken = await loginAdmin(app);
});
afterAll(async () => {
await app.close();
});
it('logs in with TikTok identity, creates an order, and unlocks paid playback through an idempotent payment webhook', async () => {
const login = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'test-code',
mockOpenId: 'open-user-1',
nickname: 'Tester',
})
.expect(201);
expect(login.body.data).toMatchObject({
accessToken: expect.any(String),
user: {
id: expect.any(Number),
tiktokOpenId: 'open-user-1',
nickname: 'Tester',
},
isNewUser: true,
});
const token = login.body.data.accessToken;
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Paid Drama',
coverUrl: 'https://cdn.example.com/paid.jpg',
type: 'paid',
status: 'published',
})
.expect(201);
const episode = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Paid Episode',
coverUrl: 'https://cdn.example.com/paid-ep.jpg',
videoUrl: 'https://cdn.example.com/paid-ep.mp4',
durationSeconds: 300,
isPaid: true,
unlockPrice: 199,
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.get(`/api/app/v1/episodes/${episode.body.data.id}/play`)
.expect(401);
const lockedPlay = await request(app.getHttpServer())
.get(`/api/app/v1/episodes/${episode.body.data.id}/play`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(lockedPlay.body.data).toMatchObject({
episodeId: episode.body.data.id,
isLocked: true,
videoUrl: null,
});
const order = await request(app.getHttpServer())
.post('/api/app/v1/orders')
.set('Authorization', `Bearer ${token}`)
.send({ episodeId: episode.body.data.id })
.expect(201);
expect(order.body.data).toMatchObject({
orderNo: expect.any(String),
status: 'pending',
amount: 199,
});
await request(app.getHttpServer())
.post('/api/webhooks/tiktok/payments')
.send({
orderNo: order.body.data.orderNo,
providerTransactionId: 'txn-1',
status: 'paid',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/webhooks/tiktok/payments')
.send({
orderNo: order.body.data.orderNo,
providerTransactionId: 'txn-1',
status: 'paid',
})
.expect(201);
const unlockedPlay = await request(app.getHttpServer())
.get(`/api/app/v1/episodes/${episode.body.data.id}/play`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(unlockedPlay.body.data).toMatchObject({
episodeId: episode.body.data.id,
isLocked: false,
videoUrl: 'https://cdn.example.com/paid-ep.mp4',
});
});
});

View File

@@ -0,0 +1,77 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers';
describe('Playback error logs', () => {
let app: INestApplication;
let adminToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrapApp(app);
await app.init();
adminToken = await loginAdmin(app);
});
afterAll(async () => {
await app.close();
});
it('accepts playback error reports and exposes them to admin logs', async () => {
const requestId = 'aac95ef4-c546-427d-88c7-25b15b396b91';
const createResponse = await request(app.getHttpServer())
.post('/api/app/v1/logs/playback-errors')
.set('X-Request-Id', requestId)
.send({
requestId,
dramaId: 1,
episodeId: 10,
playUrl: 'https://cdn.example.com/video.mp4',
playerErrorCode: 'MEDIA_DECODE_ERROR',
message: 'Video decode failed',
progressSeconds: 128,
networkType: 'wifi',
device: {
platform: 'ios',
model: 'iPhone',
systemVersion: '17.0',
appVersion: '1.0.0',
},
occurredAt: '2026-06-30T00:00:00.000Z',
})
.expect(201);
expect(createResponse.body).toMatchObject({
code: 0,
data: {
id: expect.any(Number),
requestId,
},
message: 'success',
requestId,
});
const listResponse = await request(app.getHttpServer())
.get('/api/admin/v1/logs/playback-errors')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(listResponse.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
requestId,
playerErrorCode: 'MEDIA_DECODE_ERROR',
progressSeconds: 128,
}),
]),
);
});
});

14
test/test-helpers.ts Normal file
View File

@@ -0,0 +1,14 @@
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
export async function loginAdmin(app: INestApplication) {
const response = await request(app.getHttpServer())
.post('/api/admin/v1/auth/login')
.send({
username: 'admin',
password: 'admin123',
})
.expect(201);
return response.body.data.accessToken as string;
}