Files
cyh-tk-backend/test/dramas.e2e-spec.ts
zhxiao1124 55e20f9f16 feat(dramas): add play count field for drama records
1. 新增plays字段到DramaRecord接口和swagger/openapi文档
2. 在剧集创建时默认设置plays为0
3. 新增getPlayCountsByDramaIds方法聚合剧集播放量
4. 在后台和前台剧集列表中添加播放量数据
5. 更新测试用例适配新增的plays字段
2026-07-02 18:25:58 +08:00

512 lines
16 KiB
TypeScript

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('creates episode slots when a new drama declares total episodes', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Preconfigured Slots Drama',
coverUrl: 'https://cdn.example.com/preconfigured-slots.jpg',
type: 'romance',
status: 'draft',
totalEpisodes: 2,
})
.expect(201);
expect(drama.body.data).toMatchObject({
title: 'Preconfigured Slots Drama',
totalEpisodes: 2,
publishedEpisodes: 0,
});
const episodes = await request(app.getHttpServer())
.get(`/api/admin/v1/dramas/${drama.body.data.id}/episodes`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(episodes.body.data.list).toEqual([
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 1,
title: '第1集',
publishStatus: 'draft',
}),
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 2,
title: '第2集',
publishStatus: 'draft',
}),
]);
});
it('updates an episode from the drama detail episodes endpoint', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Nested Episode Update Drama',
coverUrl: 'https://cdn.example.com/nested-update.jpg',
type: 'action',
status: 'draft',
totalEpisodes: 1,
})
.expect(201);
const episodes = await request(app.getHttpServer())
.get(`/api/admin/v1/dramas/${drama.body.data.id}/episodes`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const episodeId = episodes.body.data.list[0].id;
const updated = await request(app.getHttpServer())
.patch(`/api/admin/v1/dramas/${drama.body.data.id}/episodes/${episodeId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Updated Nested Episode',
description: 'Updated from drama detail.',
durationSeconds: 240,
freeType: 'paid',
price: 199,
})
.expect(200);
expect(updated.body.data).toMatchObject({
id: episodeId,
dramaId: drama.body.data.id,
title: 'Updated Nested Episode',
description: 'Updated from drama detail.',
durationSeconds: 240,
freeType: 'paid',
isPaid: true,
price: 199,
});
});
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',
portraitCoverUrl: 'https://cdn.example.com/pub-portrait.jpg',
landscapeCoverUrl: 'https://cdn.example.com/pub-landscape.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',
plays: 0,
}),
]),
);
const publishedDrama = dramas.body.data.list.find(
(item: { title: string }) => item.title === 'Published Drama',
);
expect(publishedDrama).not.toHaveProperty('portraitCoverUrl');
expect(publishedDrama).not.toHaveProperty('landscapeCoverUrl');
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',
}),
]);
});
it('filters published dramas by title and defaults app page size to 10', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Searchable Moon Drama',
coverUrl: 'https://cdn.example.com/searchable-moon.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Hidden Sun Drama',
coverUrl: 'https://cdn.example.com/hidden-sun.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas')
.query({ title: 'Moon' })
.expect(200);
expect(response.body.data.pagination.pageSize).toBe(10);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Searchable Moon Drama' }),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Hidden Sun Drama' }),
]),
);
});
it('filters admin dramas by title and recommended status', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Recommended Search Drama',
coverUrl: 'https://cdn.example.com/recommended-search.jpg',
type: 'romance',
status: 'draft',
isRecommended: true,
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Recommended Hidden Drama',
coverUrl: 'https://cdn.example.com/recommended-hidden.jpg',
type: 'romance',
status: 'draft',
isRecommended: false,
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.query({ title: 'Recommended', isRecommended: 'true' })
.expect(200);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Recommended Search Drama',
isRecommended: true,
plays: 0,
}),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Recommended Hidden Drama' }),
]),
);
});
it('returns a published recommended drama list to app APIs', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Published Recommended Drama',
coverUrl: 'https://cdn.example.com/published-recommended.jpg',
type: 'romance',
status: 'published',
isRecommended: true,
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Published Normal Drama',
coverUrl: 'https://cdn.example.com/published-normal.jpg',
type: 'romance',
status: 'published',
isRecommended: false,
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas/recommended')
.expect(200);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Published Recommended Drama',
isRecommended: true,
}),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Published Normal Drama' }),
]),
);
});
it('presearches published dramas by drama title for mini app clients', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Skyline Keyword Saga',
description: 'A searchable drama description.',
coverUrl: 'https://cdn.example.com/skyline-drama.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
const episodeOnlyDrama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Episode Only Drama',
coverUrl: 'https://cdn.example.com/episode-only-drama.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: episodeOnlyDrama.body.data.id,
episodeNo: 1,
title: 'Skyline Episode Title Only',
description: 'This should not make the drama match.',
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
byteplusVid: 'vid_skyline_001',
durationSeconds: 180,
freeType: 'free',
price: 0,
status: 'published',
publishStatus: 'published',
reviewStatus: 'approved',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Skyline Draft Drama',
coverUrl: 'https://cdn.example.com/skyline-draft-drama.jpg',
type: 'romance',
status: 'draft',
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas/presearch')
.query({ keyword: 'skyline' })
.expect(200);
expect(response.body.data.pagination.pageSize).toBe(10);
expect(response.body.data.list).toEqual([
expect.objectContaining({
title: 'Skyline Keyword Saga',
coverUrl: 'https://cdn.example.com/skyline-drama.jpg',
status: 'published',
}),
]);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Episode Only Drama' }),
expect.objectContaining({ title: 'Skyline Draft Drama' }),
]),
);
const emptyKeyword = await request(app.getHttpServer())
.get('/api/app/v1/dramas/presearch')
.query({ keyword: ' ' })
.expect(200);
expect(emptyKeyword.body.data).toEqual({
list: [],
pagination: { page: 1, pageSize: 10, total: 0 },
});
});
});