Files
cyh-tk-backend/test/database-persistence.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

267 lines
8.6 KiB
TypeScript

import { AdminService } from '../src/modules/admin/admin.service';
import { DataService } from '../src/modules/data/data.service';
import { TikTokDataProvider } from '../src/modules/data/providers/tiktok-data.provider';
import { PublishStatus } from '../src/modules/dramas/drama-status.enum';
import { DramasService } from '../src/modules/dramas/dramas.service';
import { LogsService } from '../src/modules/logs/logs.service';
import { UsersService } from '../src/modules/users/users.service';
function createRepository<T extends { id?: number }>() {
const records: T[] = [];
const matchesWhere = (item: T, where?: Partial<T>) => {
if (!where) {
return true;
}
return Object.entries(where).every(
([key, value]) => item[key as keyof T] === value,
);
};
return {
records,
create: jest.fn((input: Partial<T>) => input as T),
save: jest.fn(async (input: T) => {
if (!input.id) {
input.id = records.length + 1;
}
const index = records.findIndex((item) => item.id === input.id);
if (index >= 0) {
records[index] = input;
} else {
records.push(input);
}
return input;
}),
findOne: jest.fn(async ({ where }: { where: Partial<T> }) =>
records.find((item) => matchesWhere(item, where)) ?? null,
),
find: jest.fn(async () => [...records]),
findAndCount: jest.fn(
async ({ where, skip = 0, take = records.length }: { where?: Partial<T>; skip?: number; take?: number }) => {
const filtered = records.filter((item) => matchesWhere(item, where));
return [filtered.slice(skip, skip + take), filtered.length];
},
),
count: jest.fn(async ({ where }: { where?: Partial<T> } = {}) =>
records.filter((item) => matchesWhere(item, where)).length,
),
};
}
function createDataSource(repositories: Record<string, unknown>) {
return {
isInitialized: true,
getRepository: jest.fn((entity: { name: string }) => {
const repository = repositories[entity.name];
if (!repository) {
throw new Error(`Missing repository for ${entity.name}`);
}
return repository;
}),
};
}
describe('database-backed persistence', () => {
it('persists permissions and admin profile changes through TypeORM repositories', async () => {
const permissionRepository = createRepository();
const roleRepository = createRepository();
const adminUserRepository = createRepository();
const dataSource = createDataSource({
Permission: permissionRepository,
Role: roleRepository,
AdminUser: adminUserRepository,
});
const service = new AdminService(
{ signAsync: jest.fn() } as never,
dataSource as never,
);
await service.onModuleInit();
const permissions = await service.listPermissions({ page: 1, pageSize: 50 });
expect(permissions.list).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'permission:read', name: '查看权限' }),
expect.objectContaining({ code: 'data:read', name: '查看数据' }),
]),
);
const updated = await service.updateProfile(1, {
nickname: '数据库管理员',
email: 'db-admin@example.com',
});
expect(updated).toMatchObject({
id: 1,
nickname: '数据库管理员',
email: 'db-admin@example.com',
});
expect(adminUserRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
nickname: '数据库管理员',
email: 'db-admin@example.com',
}),
);
});
it('persists app users through TypeORM repositories when a DataSource is configured', async () => {
const userRepository = createRepository();
const dataSource = createDataSource({ User: userRepository });
const service = new UsersService(dataSource as never);
const result = await service.upsertTikTokUser({
tiktokOpenId: 'open-db-1',
nickname: 'DB User',
});
expect(result.isNewUser).toBe(true);
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
tiktokOpenId: 'open-db-1',
nickname: 'DB User',
status: 'active',
}),
);
});
it('persists request logs and playback error logs through TypeORM repositories', async () => {
const requestLogRepository = createRepository();
const playbackErrorLogRepository = createRepository();
const dataSource = createDataSource({
RequestLog: requestLogRepository,
PlaybackErrorLog: playbackErrorLogRepository,
});
const service = new LogsService(dataSource as never);
await service.recordRequest({
requestId: '00000000-0000-4000-8000-000000000001',
method: 'GET',
path: '/api/admin/v1/logs',
statusCode: 200,
responseCode: 0,
message: 'success',
costMs: 3,
});
await service.createPlaybackError({
requestId: '00000000-0000-4000-8000-000000000002',
dramaId: 1,
episodeId: 2,
playUrl: 'https://cdn.example.com/video.mp4',
playerErrorCode: 'NETWORK',
message: 'network error',
occurredAt: '2026-07-01T00:00:00.000Z',
});
expect(requestLogRepository.save).toHaveBeenCalled();
expect(playbackErrorLogRepository.save).toHaveBeenCalled();
});
it('persists synced drama and episode metrics through TypeORM repositories', async () => {
const dramaMetricRepository = createRepository();
const episodeMetricRepository = createRepository();
const syncJobRepository = createRepository();
const dataSource = createDataSource({
DramaMetric: dramaMetricRepository,
EpisodeMetric: episodeMetricRepository,
DataSyncJob: syncJobRepository,
});
const dramasService = new DramasService();
const tiktokDataProvider = new TikTokDataProvider();
const service = new DataService(
dramasService,
tiktokDataProvider,
dataSource as never,
);
const drama = await dramasService.createDrama({
title: 'DB Metrics',
coverUrl: 'https://cdn.example.com/cover.jpg',
type: 'action',
status: PublishStatus.Published,
});
await dramasService.createEpisode({
dramaId: drama.id,
episodeNo: 1,
title: 'DB Metrics Episode',
coverUrl: drama.coverUrl,
videoUrl: 'https://cdn.example.com/video.mp4',
durationSeconds: 180,
status: PublishStatus.Published,
});
await service.sync({ dramaId: drama.id });
expect(dramaMetricRepository.save).toHaveBeenCalled();
expect(episodeMetricRepository.save).toHaveBeenCalled();
expect(syncJobRepository.save).toHaveBeenCalled();
});
it('aggregates episode metric plays for database-backed drama lists', async () => {
const now = new Date('2026-07-02T00:00:00.000Z');
const dramaRepository = createRepository<any>();
const episodeRepository = createRepository<any>();
const queryBuilder = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn(async () => [{ dramaId: '1', plays: '321' }]),
};
const episodeMetricRepository = {
createQueryBuilder: jest.fn(() => queryBuilder),
};
const dataSource = createDataSource({
Drama: dramaRepository,
Episode: episodeRepository,
EpisodeMetric: episodeMetricRepository,
});
const service = new DramasService(dataSource as never);
await dramaRepository.save({
id: 1,
title: 'DB Plays Drama',
coverUrl: 'https://cdn.example.com/db-plays.jpg',
type: 'action',
tags: [],
status: PublishStatus.Published,
sortOrder: 0,
isRecommended: false,
onlineVersion: 1,
createdAt: now,
updatedAt: now,
});
await episodeRepository.save({
id: 1,
dramaId: 1,
episodeNo: 1,
title: 'Episode 1',
coverUrl: 'https://cdn.example.com/ep1.jpg',
durationSeconds: 180,
isPaid: false,
freeType: 'free',
price: 0,
status: PublishStatus.Published,
publishStatus: PublishStatus.Published,
reviewStatus: 'approved',
createdAt: now,
updatedAt: now,
});
const adminList = await service.listAdminDramas({ page: 1, pageSize: 10 });
const appList = await service.listPublishedDramas({ page: 1, pageSize: 10 });
expect(adminList.list[0]).toMatchObject({
title: 'DB Plays Drama',
plays: 321,
});
expect(appList.list[0]).toMatchObject({
title: 'DB Plays Drama',
plays: 321,
});
expect(queryBuilder.where).toHaveBeenCalledWith(
'metric.dramaId IN (:...dramaIds)',
{ dramaIds: [1] },
);
});
});