Compare commits

...

3 Commits

Author SHA1 Message Date
61a00286fb refactor(history): 重构播放历史模块,统一命名并迁移代码
将原playback-history模块重命名为history模块,统一所有相关文件的命名空间,移除冗余的重复代码,更新全局模块引用和测试用例接口路径
2026-07-02 17:28:57 +08:00
79aeac7f37 feat(playback-history): 添加播放历史模块功能
实现了播放历史的新增/更新、列表查询功能,支持JWT鉴权,同时兼容内存和数据库两种存储模式,关联了剧集和用户信息
2026-07-02 17:28:51 +08:00
fca247c3f2 feat(dramas): 新增剧集搜索、推荐接口与管理筛选功能
1.  为后台剧集列表添加标题和推荐状态筛选参数
2.  新增前台推荐剧集列表接口和前置搜索接口
3.  添加可选布尔值解析工具方法
4.  新增对应接口的端到端测试用例
2026-07-02 17:26:08 +08:00
12 changed files with 1698 additions and 933 deletions

View File

@@ -10,6 +10,7 @@ import { LogsModule } from "./modules/logs/logs.module";
import { MediaModule } from "./modules/media/media.module"; import { MediaModule } from "./modules/media/media.module";
import { OrdersModule } from "./modules/orders/orders.module"; import { OrdersModule } from "./modules/orders/orders.module";
import { PlayerModule } from "./modules/player/player.module"; import { PlayerModule } from "./modules/player/player.module";
import { HistoryModule } from "./modules/history/history.module";
@Module({ @Module({
imports: [ imports: [
@@ -26,6 +27,7 @@ import { PlayerModule } from "./modules/player/player.module";
AuthModule, AuthModule,
OrdersModule, OrdersModule,
PlayerModule, PlayerModule,
HistoryModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -14,6 +14,7 @@ import { Cover } from '../media/entities/cover.entity';
import { MediaUploadJob } from '../media/entities/media-upload-job.entity'; import { MediaUploadJob } from '../media/entities/media-upload-job.entity';
import { Order } from '../orders/entities/order.entity'; import { Order } from '../orders/entities/order.entity';
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity'; import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
import { UserPlaybackHistory } from '../history/entities/user-playback-history.entity';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
const databaseImports = process.env.DB_HOST const databaseImports = process.env.DB_HOST
@@ -40,6 +41,7 @@ const databaseImports = process.env.DB_HOST
MediaUploadJob, MediaUploadJob,
Order, Order,
UserEpisodeUnlock, UserEpisodeUnlock,
UserPlaybackHistory,
User, User,
], ],
synchronize: process.env.DB_SYNCHRONIZE === 'true', synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

@@ -28,10 +28,12 @@ export class DramasController {
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("drama:read") @RequirePermissions("drama:read")
listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) { listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string, @Query("isRecommended") isRecommended?: string) {
return this.dramasService.listAdminDramas({ return this.dramasService.listAdminDramas({
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 20, pageSize: Number(pageSize) || 20,
title,
isRecommended: this.parseOptionalBoolean(isRecommended),
}); });
} }
@@ -111,11 +113,27 @@ export class DramasController {
} }
@Get("/api/app/v1/dramas") @Get("/api/app/v1/dramas")
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string) { listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listPublishedDramas({ return this.dramasService.listPublishedDramas({
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 10, pageSize: Number(pageSize) || 10,
title, });
}
@Get("/api/app/v1/dramas/recommended")
listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listRecommendedDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
});
}
@Get("/api/app/v1/dramas/presearch")
presearchVideos(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.presearchVideos({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
keyword,
}); });
} }
@@ -131,4 +149,14 @@ export class DramasController {
getPublishedDrama(@Param("id") id: string) { getPublishedDrama(@Param("id") id: string) {
return this.dramasService.getPublishedDramaOrThrow(Number(id)); return this.dramasService.getPublishedDramaOrThrow(Number(id));
} }
private parseOptionalBoolean(value?: string) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return undefined;
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class UpsertPlaybackHistoryDto {
@ApiProperty()
@IsInt()
@Min(1)
episodeId: number;
@ApiProperty()
@IsInt()
@Min(0)
progressSeconds: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
completed?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
occurredAt?: string;
}

View File

@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('user_playback_histories')
@Index(['userId', 'episodeId'], { unique: true })
@Index(['userId', 'lastPlayedAt'])
export class UserPlaybackHistory {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'bigint' })
userId: number;
@Column({ type: 'bigint' })
dramaId: number;
@Column({ type: 'bigint' })
episodeId: number;
@Column({ type: 'int', default: 0 })
progressSeconds: number;
@Column({ type: 'int', default: 0 })
durationSeconds: number;
@Column({ type: 'boolean', default: false })
completed: boolean;
@Column({ type: 'datetime' })
lastPlayedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,37 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UserRecord } from '../users/interfaces/user-record.interface';
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
import { HistoryService } from './history.service';
@ApiTags('History')
@Controller('/api/app/v1/history')
export class HistoryController {
constructor(private readonly historyService: HistoryService) {}
@Post()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
upsertHistory(
@CurrentUser() user: UserRecord,
@Body() dto: UpsertPlaybackHistoryDto,
) {
return this.historyService.upsertHistory(user, dto);
}
@Get()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
listHistory(
@CurrentUser() user: UserRecord,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.historyService.listHistory(user, {
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DramasModule } from '../dramas/dramas.module';
import { UsersModule } from '../users/users.module';
import { HistoryController } from './history.controller';
import { HistoryService } from './history.service';
@Module({
imports: [AuthModule, DramasModule, UsersModule],
controllers: [HistoryController],
providers: [HistoryService],
})
export class HistoryModule {}

View File

@@ -0,0 +1,223 @@
import { Injectable, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import {
DramaRecord,
EpisodeRecord,
} from '../dramas/interfaces/drama-records.interface';
import { UserRecord } from '../users/interfaces/user-record.interface';
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
import { UserPlaybackHistory } from './entities/user-playback-history.entity';
import {
PlaybackHistoryListItem,
PlaybackHistoryRecord,
} from './interfaces/history-record.interface';
interface PaginationInput {
page: number;
pageSize: number;
}
@Injectable()
export class HistoryService {
private historySequence = 1;
private readonly histories: PlaybackHistoryRecord[] = [];
constructor(
private readonly dramasService: DramasService,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async upsertHistory(
user: UserRecord,
dto: UpsertPlaybackHistoryDto,
): Promise<PlaybackHistoryRecord> {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
dto.episodeId,
);
const progressSeconds = this.resolveProgress(
dto.progressSeconds,
episode.durationSeconds,
);
const completed =
Boolean(dto.completed) || progressSeconds >= episode.durationSeconds;
const lastPlayedAt = dto.occurredAt
? new Date(dto.occurredAt)
: new Date();
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(UserPlaybackHistory);
const existing = await repository.findOne({
where: { userId: user.id, episodeId: episode.id },
});
const entity =
existing ??
repository.create({
userId: user.id,
dramaId: episode.dramaId,
episodeId: episode.id,
});
entity.dramaId = episode.dramaId;
entity.progressSeconds = progressSeconds;
entity.durationSeconds = episode.durationSeconds;
entity.completed = completed;
entity.lastPlayedAt = lastPlayedAt;
return this.toRecord(await repository.save(entity));
}
const now = new Date().toISOString();
const existing = this.histories.find(
(item) => item.userId === user.id && item.episodeId === episode.id,
);
if (existing) {
existing.dramaId = episode.dramaId;
existing.progressSeconds = progressSeconds;
existing.durationSeconds = episode.durationSeconds;
existing.completed = completed;
existing.lastPlayedAt = lastPlayedAt.toISOString();
existing.updatedAt = now;
return existing;
}
const record: PlaybackHistoryRecord = {
id: this.historySequence++,
userId: user.id,
dramaId: episode.dramaId,
episodeId: episode.id,
progressSeconds,
durationSeconds: episode.durationSeconds,
completed,
lastPlayedAt: lastPlayedAt.toISOString(),
createdAt: now,
updatedAt: now,
};
this.histories.push(record);
return record;
}
async listHistory(
user: UserRecord,
pagination: PaginationInput,
): Promise<PaginatedResponse<PlaybackHistoryListItem>> {
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const repository = this.dataSource.getRepository(UserPlaybackHistory);
const [records, total] = await repository.findAndCount({
where: { userId: user.id },
order: { lastPlayedAt: 'DESC', id: 'DESC' },
skip,
take,
});
const list = await Promise.all(
records.map((record) => this.toListItem(this.toRecord(record))),
);
return { list, pagination: { page, pageSize, total } };
}
const page = this.paginate(
this.histories
.filter((item) => item.userId === user.id)
.sort((left, right) => {
const timeDiff =
new Date(right.lastPlayedAt).getTime() -
new Date(left.lastPlayedAt).getTime();
return timeDiff || right.id - left.id;
}),
pagination,
);
return {
...page,
list: await Promise.all(
page.list.map((record) => this.toListItem(record)),
),
};
}
private async toListItem(
record: PlaybackHistoryRecord,
): Promise<PlaybackHistoryListItem> {
const [drama, episode] = await Promise.all([
this.dramasService.getPublishedDramaOrThrow(record.dramaId),
this.dramasService.getPublishedEpisodeOrThrow(record.episodeId),
]);
return this.mergeListItem(record, drama, episode);
}
private mergeListItem(
record: PlaybackHistoryRecord,
drama: DramaRecord,
episode: EpisodeRecord,
): PlaybackHistoryListItem {
return {
id: record.id,
dramaId: record.dramaId,
episodeId: record.episodeId,
episodeNo: episode.episodeNo,
title: episode.title,
coverUrl: episode.coverUrl,
progressSeconds: record.progressSeconds,
durationSeconds: record.durationSeconds,
completed: record.completed,
lastPlayedAt: record.lastPlayedAt,
dramaTitle: drama.title,
dramaCoverUrl: drama.coverUrl,
};
}
private resolveProgress(progressSeconds: number, durationSeconds: number) {
return Math.min(Math.max(progressSeconds, 0), durationSeconds);
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toRecord(entity: UserPlaybackHistory): PlaybackHistoryRecord {
return {
id: Number(entity.id),
userId: Number(entity.userId),
dramaId: Number(entity.dramaId),
episodeId: Number(entity.episodeId),
progressSeconds: entity.progressSeconds,
durationSeconds: entity.durationSeconds,
completed: entity.completed,
lastPlayedAt: this.toIso(entity.lastPlayedAt),
createdAt: this.toIso(entity.createdAt),
updatedAt: this.toIso(entity.updatedAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
private resolvePagination(input: PaginationInput) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
}
private paginate<T>(
records: T[],
input: PaginationInput,
): PaginatedResponse<T> {
const { page, pageSize, skip, take } = this.resolvePagination(input);
return {
list: records.slice(skip, skip + take),
pagination: { page, pageSize, total: records.length },
};
}
}

View File

@@ -0,0 +1,27 @@
export interface PlaybackHistoryRecord {
id: number;
userId: number;
dramaId: number;
episodeId: number;
progressSeconds: number;
durationSeconds: number;
completed: boolean;
lastPlayedAt: string;
createdAt: string;
updatedAt: string;
}
export interface PlaybackHistoryListItem {
id: number;
dramaId: number;
episodeId: number;
episodeNo: number;
title: string;
coverUrl: string;
progressSeconds: number;
durationSeconds: number;
completed: boolean;
lastPlayedAt: string;
dramaTitle: string;
dramaCoverUrl: string;
}

View File

@@ -212,6 +212,8 @@ describe('Dramas and episodes', () => {
.send({ .send({
title: 'Published Drama', title: 'Published Drama',
coverUrl: 'https://cdn.example.com/pub.jpg', 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', type: 'action',
status: 'published', status: 'published',
}) })
@@ -265,6 +267,11 @@ describe('Dramas and episodes', () => {
expect.objectContaining({ title: 'Published Drama' }), expect.objectContaining({ title: 'Published Drama' }),
]), ]),
); );
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(dramas.body.data.list).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]), expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]),
); );
@@ -321,4 +328,178 @@ describe('Dramas and episodes', () => {
]), ]),
); );
}); });
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,
}),
]),
);
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 approved videos by keyword for mini app clients', async () => {
const drama = 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);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Pilot Video Match',
description: 'The first skyline episode.',
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/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 2,
title: 'Hidden Draft Video',
description: 'Contains skyline but is not published.',
coverUrl: 'https://cdn.example.com/skyline-ep2.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep2.mp4',
byteplusVid: 'vid_skyline_002',
durationSeconds: 180,
status: 'draft',
publishStatus: 'draft',
reviewStatus: 'approved',
})
.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({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Pilot Video 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,
dramaTitle: 'Skyline Keyword Saga',
dramaCoverUrl: 'https://cdn.example.com/skyline-drama.jpg',
}),
]);
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 },
});
});
}); });

View File

@@ -128,4 +128,164 @@ describe('Auth, orders, and playback', () => {
videoUrl: 'https://cdn.example.com/paid-ep.mp4', videoUrl: 'https://cdn.example.com/paid-ep.mp4',
}); });
}); });
it('records, updates, and isolates mini app playback history', async () => {
const firstLogin = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-1',
mockOpenId: 'history-user-1',
nickname: 'History User 1',
})
.expect(201);
const firstToken = firstLogin.body.data.accessToken;
const secondLogin = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-2',
mockOpenId: 'history-user-2',
nickname: 'History User 2',
})
.expect(201);
const secondToken = secondLogin.body.data.accessToken;
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'History Drama',
coverUrl: 'https://cdn.example.com/history-drama.jpg',
type: 'history',
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: 'History Episode',
coverUrl: 'https://cdn.example.com/history-episode.jpg',
videoUrl: 'https://cdn.example.com/history-episode.mp4',
durationSeconds: 300,
status: 'published',
publishStatus: 'published',
reviewStatus: 'approved',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/history')
.send({
episodeId: episode.body.data.id,
progressSeconds: 86,
completed: false,
occurredAt: '2026-07-02T10:00:00.000Z',
})
.expect(401);
await request(app.getHttpServer())
.post('/api/app/v1/history')
.set('Authorization', `Bearer ${firstToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 86,
completed: false,
occurredAt: '2026-07-02T10:00:00.000Z',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/history')
.set('Authorization', `Bearer ${firstToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 301,
completed: false,
occurredAt: '2026-07-02T10:05:00.000Z',
})
.expect(201);
const firstHistory = await request(app.getHttpServer())
.get('/api/app/v1/history')
.set('Authorization', `Bearer ${firstToken}`)
.expect(200);
expect(firstHistory.body.data).toMatchObject({
pagination: { page: 1, pageSize: 20, total: 1 },
list: [
{
dramaId: drama.body.data.id,
episodeId: episode.body.data.id,
episodeNo: 1,
title: 'History Episode',
coverUrl: 'https://cdn.example.com/history-episode.jpg',
progressSeconds: 300,
durationSeconds: 300,
completed: true,
lastPlayedAt: '2026-07-02T10:05:00.000Z',
dramaTitle: 'History Drama',
dramaCoverUrl: 'https://cdn.example.com/history-drama.jpg',
},
],
});
const secondHistory = await request(app.getHttpServer())
.get('/api/app/v1/history')
.set('Authorization', `Bearer ${secondToken}`)
.expect(200);
expect(secondHistory.body.data).toMatchObject({
pagination: { page: 1, pageSize: 20, total: 0 },
list: [],
});
});
it('rejects playback history for unavailable episodes', async () => {
const login = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-3',
mockOpenId: 'history-user-3',
})
.expect(201);
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Draft History Drama',
coverUrl: 'https://cdn.example.com/draft-history-drama.jpg',
type: 'history',
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: 'Draft History Episode',
coverUrl: 'https://cdn.example.com/draft-history-episode.jpg',
durationSeconds: 120,
status: 'draft',
publishStatus: 'draft',
reviewStatus: 'not_submitted',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/history')
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 10,
})
.expect(404);
});
}); });