1. 重构项目文件结构,统一文件存放位置与命名规则 2. 配置tsconfig路径别名,替换原有相对路径导入 3. 迁移drama-status枚举到dramas.types统一管理 4. 新增全局配置文件与工具类,补充完整注释与文档 5. 修复所有导入路径与依赖引用问题
267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
import { AdminService } from '../src/admin/admin.service';
|
|
import { DataService } from '../src/data/data.service';
|
|
import { TikTokDataProvider } from '../src/data/data.provider';
|
|
import { PublishStatus } from '../src/dramas/dramas.types';
|
|
import { DramasService } from '../src/dramas/dramas.service';
|
|
import { LogsService } from '../src/logs/logs.service';
|
|
import { UsersService } from '../src/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] },
|
|
);
|
|
});
|
|
});
|