chore: 初始化TikTok短剧后端项目基础架构
创建了完整的NestJS项目结构,包含配置文件、核心模块、工具类、拦截器、过滤器等基础代码,实现了健康检查、用户认证、管理员RBAC、剧集管理、订单支付、媒体管理等核心功能的基础框架,配置了ESLint、TypeScript、Jest等开发工具链。
This commit is contained in:
375
test/admin-permissions-users.e2e-spec.ts
Normal file
375
test/admin-permissions-users.e2e-spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
111
test/api-standard.e2e-spec.ts
Normal file
111
test/api-standard.e2e-spec.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
237
test/core-modules.e2e-spec.ts
Normal file
237
test/core-modules.e2e-spec.ts
Normal 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
200
test/dramas.e2e-spec.ts
Normal 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
10
test/jest-e2e.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "..",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"watchman": false,
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
131
test/orders-playback.e2e-spec.ts
Normal file
131
test/orders-playback.e2e-spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
77
test/playback-error.e2e-spec.ts
Normal file
77
test/playback-error.e2e-spec.ts
Normal 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
14
test/test-helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user