文件
OmniCard/backend/test/modules/card/article.test.ts

217 行
7.7 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import * as fs from 'fs';
import * as path from 'path';
import { BaseController } from '@cool-midway/core';
import { CardArticleEntity } from '../../../src/modules/card/entity/article';
import { CardArticleService } from '../../../src/modules/card/service/article';
import {
AdminCardArticleController,
adminCardArticleCurdOption,
} from '../../../src/modules/card/controller/admin/article';
describe('后台博客文章与动态发布中心 (Card Article CoolController & Service)', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/article.ts'
);
const serviceFile = path.join(
__dirname,
'../../../src/modules/card/service/article.ts'
);
const entityFile = path.join(
__dirname,
'../../../src/modules/card/entity/article.ts'
);
describe('1. 路由与 CoolController CRUD 契约', () => {
it('文件路径应符合 /admin/card/article 自动路由与架构约定', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
expect(fs.existsSync(serviceFile)).toBe(true);
expect(fs.existsSync(entityFile)).toBe(true);
expect(controllerFile.replace(/\\/g, '/')).toMatch(
/modules\/card\/controller\/admin\/article\.ts$/
);
});
it('控制器应继承 BaseController 并正确配置 entity 与 service', () => {
expect(AdminCardArticleController.prototype).toBeInstanceOf(BaseController);
expect(adminCardArticleCurdOption.entity).toBe(CardArticleEntity);
expect(adminCardArticleCurdOption.service).toBe(CardArticleService);
});
it('装饰器 api 必须完整包含 add/delete/update/info/list/page', () => {
expect(adminCardArticleCurdOption.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
});
it('分页查询 pageQueryOp 应支持标题搜索与发布状态筛选', () => {
const pageQueryOp = adminCardArticleCurdOption.pageQueryOp;
expect(pageQueryOp.keyWordLikeFields).toEqual(
expect.arrayContaining(['a.title', 'a.summary'])
);
expect(pageQueryOp.fieldEq).toEqual(
expect.arrayContaining([
{ column: 'a.card_id', requestParam: 'cardId' },
{ column: 'a.is_published', requestParam: 'isPublished' },
])
);
expect(pageQueryOp.addOrderBy).toEqual({ update_time: 'desc' });
});
});
describe('2. OpenGraph 社交元数据自动提炼 (generateOgMeta)', () => {
let service: CardArticleService;
beforeEach(() => {
service = new CardArticleService();
});
it('完整文章入参时,应正确映射 title、summary 与 coverImage', () => {
const article = {
cardId: 101,
title: '全球商务新视界:2026 数字名片白皮书',
summary: '本文深度剖析全球化商务场景下 NFC 与微官网结合的数字化获客新路径。',
content: '<p>正文内容...</p>',
coverImage: 'https://cdn.omnicard.ai/articles/cover-1.jpg',
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.title).toBe(article.title);
expect(og.description).toBe(article.summary);
expect(og.image).toBe(article.coverImage);
});
it('缺失 summary 时,应自动从 content 提取剥离 HTML 标签的前 160 字符', () => {
const longHtml = `
<article>
<h1>一级标题</h1>
<p>在全球数字化浪潮席卷而来的今天,<strong>智能名片</strong>已经不仅是身份凭证,更是现代企业的第一道数字门面与线索转化漏斗。通过多语言自适应与 NFC 瞬时触达,销售代表可以在国际展会现场以极低摩擦完成商机留资与精准雷达跟进,大幅提升商机转化效率与客户体验。</p>
</article>
`;
const article = {
cardId: 101,
title: '全球营销指南',
content: longHtml,
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.title).toBe('全球营销指南');
expect(og.description.length).toBeLessThanOrEqual(160);
expect(og.description).not.toContain('<p>');
expect(og.description).not.toContain('<strong>');
expect(og.description).toContain('在全球数字化浪潮席卷而来的今天');
});
it('缺失 coverImage 时,应自动从 content 中提取首张图片 URL', () => {
const contentWithImages = `
<div>
<p>前言段落</p>
<img src="https://cdn.omnicard.ai/upload/hero-img.png" alt="Hero" />
<p>次要段落</p>
<img src="https://cdn.omnicard.ai/upload/second.png" />
</div>
`;
const article = {
cardId: 102,
title: '图文资讯',
content: contentWithImages,
isPublished: false,
};
const og = service.generateOgMeta(article);
expect(og.image).toBe('https://cdn.omnicard.ai/upload/hero-img.png');
});
it('支持提取 Markdown 格式的图片语法 ![alt](url)', () => {
const mdContent = `
# 标题
这里是段落
![架构图](https://cdn.omnicard.ai/upload/architecture.webp)
`;
const article = {
cardId: 103,
title: 'Markdown 文章',
content: mdContent,
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.image).toBe('https://cdn.omnicard.ai/upload/architecture.webp');
});
it('面对空对象或未定义字段时应安全回退,不抛出异常', () => {
const og = service.generateOgMeta({});
expect(og.title).toBe('');
expect(og.description).toBe('');
expect(og.image).toBe('');
});
});
describe('3. 文章发布、下架与保存前生命周期 (publish / unpublish / modifyBefore)', () => {
let service: CardArticleService;
let mockRepo: {
findOneBy: jest.Mock;
save: jest.Mock;
};
beforeEach(() => {
service = new CardArticleService();
mockRepo = {
findOneBy: jest.fn(),
save: jest.fn().mockImplementation(async entity => entity),
};
service.cardArticleEntity = mockRepo as any;
});
it('publish 应将 isPublished 置为 true 并保存', async () => {
const existing = {
id: 1,
title: '草稿文章',
isPublished: false,
} as CardArticleEntity;
mockRepo.findOneBy.mockResolvedValue(existing);
const result = await service.publish(1);
expect(mockRepo.findOneBy).toHaveBeenCalledWith({ id: 1 });
expect(existing.isPublished).toBe(true);
expect(mockRepo.save).toHaveBeenCalledWith(existing);
expect(result.isPublished).toBe(true);
});
it('unpublish 应将 isPublished 置为 false 并保存', async () => {
const existing = {
id: 2,
title: '已发布文章',
isPublished: true,
} as CardArticleEntity;
mockRepo.findOneBy.mockResolvedValue(existing);
const result = await service.unpublish(2);
expect(mockRepo.findOneBy).toHaveBeenCalledWith({ id: 2 });
expect(existing.isPublished).toBe(false);
expect(mockRepo.save).toHaveBeenCalledWith(existing);
expect(result.isPublished).toBe(false);
});
it('modifyBefore 应在保存时自动生成或补全 ogMeta', async () => {
const data: Partial<CardArticleEntity> = {
title: '动态资讯',
content: '<p>精彩资讯动态内容发布</p>',
};
await service.modifyBefore(data, 'add');
expect(data.ogMeta).toBeDefined();
expect(data.ogMeta?.title).toBe('动态资讯');
expect(data.ogMeta?.description).toBe('精彩资讯动态内容发布');
expect(data.ogMeta?.image).toBe('');
});
});
});