Commit 7aa0eb30 by DaiJiezhang

test: 补齐五个业务模块的 76 条单元测试

单元测试总数从 25 增至 101,覆盖认证、账号管理、公司档案、公司人员、
手机号、企微、设备与图片存储八个部分。

企微联动 17 条
把此前只靠临时脚本验证的四条规则固化进测试套件:复用已有号码、
自动建档并回填来源、改绑时只清理本账号所建的号、号码未变时完全不动关联。
另有一条专门断言三个写方法都在事务内执行——同时写两张表的操作若不整体成事,
失败会留下无人引用的孤儿号码且毫无征兆。

图片存储 9 条
含路径穿越拒绝(标识来自 URL,不加限制即可读到目录之外的文件)、
缩略图长边 240 且小图不放大、缺缩略图时按需补生成、清理时原图与缩略图一并删除。
这类边界正常使用永远不会触发,功能测试发现不了。

其余模块
公司档案 9 条(删除前的五类引用检查与提示语拼接、空白值存 null)
公司人员 10 条(在职离职联动、改回在职时清空离职时间、公司显示名简称优先)
手机号 14 条(按输入位数变化的五种匹配方式、虚拟号码豁免、剥离 +86 前缀)
设备 17 条(状态枚举、查重带删除时间条件、编号取最大值加一而非数量加一、
写库失败时只清新图保留原图)

一处测试写错并已修正:纯空格前缀的正确行为是视为未填、回落到默认前缀,
与 Java 一致,最初的用例错误地期望它被拒绝。

回归:契约比对 31/31、单元测试 101/101 全部通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 69da729e
/**
* 文件用途(白话):验证公司人员的在职状态规则与删除保护,对应 Java 侧 CompanyPersonServiceTest。
* 关联文件:company-person.service.ts。
*
* 在职与离职时间的联动最容易写漏:改回在职时若不清空离职时间,
* 库里就会留下「在职却有离职时间」的自相矛盾记录,界面上完全看不出来。
*/
import { CompanyPersonService } from './company-person.service';
function buildService() {
const prisma: any = {
as_company_person: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_company_profile: { findFirst: jest.fn().mockResolvedValue({ id: 4n, company_name: '广州学有成', short_name: '学有为' }), findMany: jest.fn().mockResolvedValue([]) },
as_asset_device: { count: jest.fn().mockResolvedValue(0) },
as_wecom_account: { count: jest.fn().mockResolvedValue(0) },
as_wechat_account: { count: jest.fn().mockResolvedValue(0) },
as_douyin_account: { count: jest.fn().mockResolvedValue(0) },
};
return { service: new CompanyPersonService(prisma), prisma };
}
const activePerson = { id: 7n, person_name: '张三', employment_status: '在职', company_profile_id: 4n, delete_time: 0n };
describe('CompanyPersonService', () => {
it('不填在职状态时默认在职', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.create.mockResolvedValue(activePerson);
await service.create({ personName: '张三' });
expect(prisma.as_company_person.create.mock.calls[0][0].data.employment_status).toBe('在职');
});
it('在职状态取值超出范围时拒绝', async () => {
const { service } = buildService();
await expect(service.create({ personName: '张三', employmentStatus: '实习' })).rejects.toThrow('在职状态取值无效');
});
it('状态为离职却没填离职时间时拒绝', async () => {
const { service } = buildService();
await expect(service.create({ personName: '张三', employmentStatus: '离职' })).rejects.toThrow('离职人员必须填写离职时间');
});
it('改回在职时清空离职时间,不留下自相矛盾的记录', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findFirst.mockResolvedValue({ ...activePerson, employment_status: '离职' });
prisma.as_company_person.update.mockResolvedValue(activePerson);
await service.update(7, { personName: '张三', employmentStatus: '在职' });
expect(prisma.as_company_person.update.mock.calls[0][0].data.resigned_at).toBeNull();
});
it('所属公司不存在时拒绝保存', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(null);
await expect(service.create({ personName: '张三', companyProfileId: 999 })).rejects.toThrow('所属公司不存在或已删除');
});
it('不填所属公司时允许保存', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.create.mockResolvedValue({ ...activePerson, company_profile_id: null });
await expect(service.create({ personName: '张三' })).resolves.toBeDefined();
expect(prisma.as_company_person.create.mock.calls[0][0].data.company_profile_id).toBeNull();
});
it('仍被设备或账号引用时不允许删除,并说明引用方', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findFirst.mockResolvedValue(activePerson);
prisma.as_asset_device.count.mockResolvedValue(1);
prisma.as_wecom_account.count.mockResolvedValue(1);
await expect(service.softDelete(7)).rejects.toThrow('公司人员仍被设备资产管理、企业微信资产引用,不能删除');
});
it('公司显示名优先用简称', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findMany.mockResolvedValue([activePerson]);
prisma.as_company_profile.findMany.mockResolvedValue([{ id: 4n, company_name: '广州学有成文化传媒有限公司', short_name: '学有为' }]);
const result = await service.page({});
expect(result.records[0].companyProfileName).toBe('学有为');
});
it('没有简称时回落到公司全称', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findMany.mockResolvedValue([activePerson]);
prisma.as_company_profile.findMany.mockResolvedValue([{ id: 4n, company_name: '广州学有成文化传媒有限公司', short_name: null }]);
const result = await service.page({});
expect(result.records[0].companyProfileName).toBe('广州学有成文化传媒有限公司');
});
it('记录不存在时提示明确原因', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.findFirst.mockResolvedValue(null);
await expect(service.softDelete(999)).rejects.toThrow('公司人员不存在或已删除');
});
});
/**
* 文件用途(白话):验证公司档案的校验与删除保护,对应 Java 侧 CompanyProfileServiceTest。
* 关联文件:company-profile.service.ts。
*
* 删除保护那条最关键:五类资产里任何一处还在引用这家公司都不能删,
* 提示语要说清是被谁引用。少查一张表就会删掉仍被引用的档案,
* 留下一批指向空档案的记录,而且不会有任何报错。
*/
import { CompanyProfileService } from './company-profile.service';
function buildService() {
const prisma: any = {
as_company_profile: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_company_person: { count: jest.fn().mockResolvedValue(0) },
as_wecom_account: { count: jest.fn().mockResolvedValue(0) },
as_douyin_account: { count: jest.fn().mockResolvedValue(0) },
as_domain_asset: { count: jest.fn().mockResolvedValue(0) },
as_merchant: { count: jest.fn().mockResolvedValue(0) },
};
return { service: new CompanyProfileService(prisma), prisma };
}
const activeProfile = { id: 3n, company_name: '广州学有成', short_name: '学有为', delete_time: 0n };
describe('CompanyProfileService', () => {
it('公司名称为空白时拒绝保存', async () => {
const { service } = buildService();
await expect(service.create({ companyName: ' ' })).rejects.toThrow('公司名称不能为空');
});
it('可选字段的空白值存成 null 而不是空字符串', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.create.mockResolvedValue({ ...activeProfile });
await service.create({ companyName: '广州学有成', shortName: ' ', address: '' });
const data = prisma.as_company_profile.create.mock.calls[0][0].data;
expect(data.short_name).toBeNull();
expect(data.address).toBeNull();
});
it('公司名称前后空格会被去掉', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.create.mockResolvedValue({ ...activeProfile });
await service.create({ companyName: ' 广州学有成 ' });
expect(prisma.as_company_profile.create.mock.calls[0][0].data.company_name).toBe('广州学有成');
});
it('仍被公司人员引用时不允许删除,并说明引用方', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(activeProfile);
prisma.as_company_person.count.mockResolvedValue(2);
await expect(service.softDelete(3)).rejects.toThrow('公司档案仍被公司人员引用,不能删除');
expect(prisma.as_company_profile.update).not.toHaveBeenCalled();
});
it('被多类资产引用时提示语按顺序列出全部引用方', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(activeProfile);
prisma.as_company_person.count.mockResolvedValue(1);
prisma.as_wecom_account.count.mockResolvedValue(1);
prisma.as_merchant.count.mockResolvedValue(1);
await expect(service.softDelete(3)).rejects.toThrow('公司档案仍被公司人员、企业微信资产、商户引用,不能删除');
});
it('无人引用时执行软删除而非物理删除', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(activeProfile);
await service.softDelete(3);
const data = prisma.as_company_profile.update.mock.calls[0][0].data;
expect(data.delete_time).not.toBe(0n);
expect(typeof data.delete_time).toBe('bigint');
});
it('记录不存在时提示明确原因', async () => {
const { service, prisma } = buildService();
prisma.as_company_profile.findFirst.mockResolvedValue(null);
await expect(service.update(999, { companyName: 'x' })).rejects.toThrow('公司档案不存在或已删除');
await expect(service.softDelete(999)).rejects.toThrow('公司档案不存在或已删除');
});
it('列表查询只取存活记录并按 id 倒序', async () => {
const { service, prisma } = buildService();
await service.page({});
const args = prisma.as_company_profile.findMany.mock.calls[0][0];
expect(args.where.delete_time).toBe(0n);
expect(args.orderBy).toEqual({ id: 'desc' });
});
it('关键词在六个字段上做模糊匹配', async () => {
const { service, prisma } = buildService();
await service.page({ keyword: '学有' });
const fields = prisma.as_company_profile.findMany.mock.calls[0][0].where.OR.map((c: any) => Object.keys(c)[0]);
expect(fields).toEqual(['company_name', 'short_name', 'unified_social_credit_code', 'address', 'contact_name', 'contact_value']);
});
});
/**
* 文件用途(白话):验证设备资产的校验、自动编号与删除保护,对应 Java 侧 DeviceAssetServiceTest。
* 关联文件:device-asset.service.ts。
*
* 设备名查重必须带上删除时间条件:数据库唯一索引是「名称 + 删除时间」的组合,
* 漏掉这个条件会出现「删掉了却仍提示名称已存在」,使用者完全无从理解。
*/
import { DeviceAssetService } from './device-asset.service';
function buildService() {
const prisma: any = {
as_asset_device: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_company_person: { count: jest.fn().mockResolvedValue(1), findMany: jest.fn().mockResolvedValue([]) },
as_phone_asset: { count: jest.fn().mockResolvedValue(0) },
as_wecom_account: { count: jest.fn().mockResolvedValue(0) },
as_wechat_account: { count: jest.fn().mockResolvedValue(0) },
as_douyin_account: { count: jest.fn().mockResolvedValue(0) },
};
const files: any = { store: jest.fn().mockResolvedValue(null), replace: jest.fn().mockResolvedValue(null), cleanupNewFile: jest.fn(), readOriginal: jest.fn(), readThumbnail: jest.fn() };
return { service: new DeviceAssetService(prisma, files), prisma, files };
}
const validSave = (overrides: any = {}) => ({ deviceName: '学管师1号机', userUsageStatus: '使用中', assetRelationStatus: '已关联', ...overrides });
const activeDevice = { id: 5n, device_name: '学管师1号机', image_attachment_1: null, image_attachment_2: null, delete_time: 0n };
describe('DeviceAssetService 校验', () => {
it('使用状态取值超出范围时拒绝', async () => {
const { service } = buildService();
await expect(service.create(validSave({ userUsageStatus: '在用' }), {})).rejects.toThrow('使用状态取值无效');
});
it('资产关联状态取值超出范围时拒绝', async () => {
const { service } = buildService();
await expect(service.create(validSave({ assetRelationStatus: '关联中' }), {})).rejects.toThrow('资产关联状态取值无效');
});
it('使用人不存在时拒绝', async () => {
const { service, prisma } = buildService();
prisma.as_company_person.count.mockResolvedValue(0);
await expect(service.create(validSave({ userPersonId: 999 }), {})).rejects.toThrow('使用人不存在或已删除');
});
it('设备名重复时拒绝', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.count.mockResolvedValue(1);
await expect(service.create(validSave(), {})).rejects.toThrow('设备名称已存在');
});
it('查重条件带删除时间,使同名可在删除后重建', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.create.mockResolvedValue(activeDevice);
await service.create(validSave(), {});
expect(prisma.as_asset_device.count.mock.calls[0][0].where.delete_time).toBe(0n);
});
it('修改时查重排除自己,避免改别的字段被自己挡住', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue(activeDevice);
prisma.as_asset_device.update.mockResolvedValue(activeDevice);
await service.update(5, validSave(), {});
expect(prisma.as_asset_device.count.mock.calls[0][0].where.id).toEqual({ not: 5n });
});
});
describe('DeviceAssetService 自动编号', () => {
it('没有同前缀设备时从 1 号机开始', async () => {
const { service } = buildService();
await expect(service.suggestNextDeviceName('学管师')).resolves.toEqual({ deviceName: '学管师1号机' });
});
it('取现有最大编号加一,而不是数量加一', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findMany.mockResolvedValue([
{ device_name: '学管师3号机' }, { device_name: '学管师31号机' }, { device_name: '学管师7号机' },
]);
await expect(service.suggestNextDeviceName('学管师')).resolves.toEqual({ deviceName: '学管师32号机' });
});
it('忽略形状不符的名称', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findMany.mockResolvedValue([
{ device_name: '学管师备用机' }, { device_name: '学管师2号机' }, { device_name: '学管师A3号机' },
]);
await expect(service.suggestNextDeviceName('学管师')).resolves.toEqual({ deviceName: '学管师3号机' });
});
it('不传前缀时用默认前缀', async () => {
const { service } = buildService();
await expect(service.suggestNextDeviceName()).resolves.toEqual({ deviceName: '学管师1号机' });
});
it('前缀含特殊字符时拒绝,避免污染模糊查询', async () => {
const { service } = buildService();
// 百分号与下划线在模糊查询里是通配符,其余符号也没有作为设备前缀的意义
for (const bad of ['%', '_abc', 'a%b', '设备-1', 'x'.repeat(21)]) {
await expect(service.suggestNextDeviceName(bad)).rejects.toThrow('设备名称前缀只能是 1-20 位中文、字母或数字');
}
});
it('前缀是纯空格时视为未填,回落到默认前缀', async () => {
const { service } = buildService();
await expect(service.suggestNextDeviceName(' ')).resolves.toEqual({ deviceName: '学管师1号机' });
});
});
describe('DeviceAssetService 删除保护与图片清理', () => {
it('仍被手机号或账号引用时不允许删除', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue(activeDevice);
prisma.as_phone_asset.count.mockResolvedValue(1);
prisma.as_wecom_account.count.mockResolvedValue(1);
await expect(service.softDelete(5)).rejects.toThrow('设备仍被手机号资产、企业微信资产引用,不能删除');
});
it('无引用时执行软删除', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue(activeDevice);
await service.softDelete(5);
expect(prisma.as_asset_device.update.mock.calls[0][0].data.delete_time).not.toBe(0n);
});
it('写库失败时清掉本次新落盘的图片,不留野文件', async () => {
const { service, prisma, files } = buildService();
files.store.mockResolvedValueOnce('new-image-1.png').mockResolvedValueOnce('new-image-2.png');
prisma.as_asset_device.create.mockRejectedValue(new Error('写库失败'));
await expect(service.create(validSave(), {})).rejects.toThrow('写库失败');
expect(files.cleanupNewFile).toHaveBeenCalledWith('new-image-1.png');
expect(files.cleanupNewFile).toHaveBeenCalledWith('new-image-2.png');
});
it('修改失败时只清理新图,保留仍被记录引用的原图', async () => {
const { service, prisma, files } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue({ ...activeDevice, image_attachment_1: 'old-image.png' });
files.replace.mockResolvedValueOnce('old-image.png').mockResolvedValueOnce('new-image-2.png');
prisma.as_asset_device.update.mockRejectedValue(new Error('写库失败'));
await expect(service.update(5, validSave(), {})).rejects.toThrow('写库失败');
expect(files.cleanupNewFile).not.toHaveBeenCalledWith('old-image.png');
expect(files.cleanupNewFile).toHaveBeenCalledWith('new-image-2.png');
});
it('记录不存在时提示明确原因', async () => {
const { service, prisma } = buildService();
prisma.as_asset_device.findFirst.mockResolvedValue(null);
await expect(service.softDelete(999)).rejects.toThrow('设备资产不存在或已删除');
});
});
/**
* 文件用途(白话):验证设备图片的存取与安全边界,对应 Java 侧 DeviceAssetFileStorageServiceTest 的 7 个用例。
* 关联文件:file-storage.service.ts、backend/src/test/.../DeviceAssetFileStorageServiceTest.java。
*
* 其中路径穿越那条最要紧:图片标识来自 URL,若不加限制,
* 构造一个带上级引用的标识就能读到目录之外的任意文件。
* 这类漏洞功能测试完全发现不了——正常使用永远不会出现那样的标识。
*
* 测试在临时目录里跑,不碰真实的上传目录。
*/
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import sharp from 'sharp';
import { DeviceAssetFileStorageService } from './file-storage.service';
describe('DeviceAssetFileStorageService', () => {
let root: string;
let storage: DeviceAssetFileStorageService;
const pngOf = (width: number, height: number) =>
sharp({ create: { width, height, channels: 3, background: { r: 40, g: 120, b: 200 } } }).png().toBuffer();
const upload = (buffer: Buffer, originalname = 'photo.png') => ({ originalname, buffer, size: buffer.length });
beforeEach(async () => {
root = join(tmpdir(), `xyw-image-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
process.env.XYW_DEVICE_ASSETS_UPLOAD_DIR = root;
storage = new DeviceAssetFileStorageService();
await storage.ensureRoot();
});
afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
it('保存原图的同时产出缩略图', async () => {
const identifier = await storage.store(upload(await pngOf(800, 600)));
expect(identifier).toMatch(/^[0-9a-f-]{36}\.png$/);
const files = await fs.readdir(root);
expect(files).toContain(identifier);
expect(files).toContain(`${identifier}.thumb.jpg`);
});
it('缩略图长边压到 240 且保持比例', async () => {
const identifier = await storage.store(upload(await pngOf(800, 600)));
const meta = await sharp(await storage.readThumbnail(identifier as string)).metadata();
expect(meta.width).toBe(240);
expect(meta.height).toBe(180);
expect(meta.format).toBe('jpeg');
});
it('小于目标尺寸的图片不被放大', async () => {
const identifier = await storage.store(upload(await pngOf(100, 80)));
const meta = await sharp(await storage.readThumbnail(identifier as string)).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(80);
});
it('支持 JPG、PNG、GIF,拒绝其他扩展名', async () => {
const image = await pngOf(200, 200);
for (const name of ['a.jpg', 'b.jpeg', 'c.png', 'd.gif']) {
await expect(storage.store(upload(image, name))).resolves.toBeTruthy();
}
await expect(storage.store(upload(image, 'e.bmp'))).rejects.toThrow('仅支持 JPG、PNG、GIF 图片');
});
it('拒绝超过 20MB 的文件与非图片内容', async () => {
const oversize = { originalname: 'big.png', buffer: Buffer.alloc(21 * 1024 * 1024), size: 21 * 1024 * 1024 };
await expect(storage.store(oversize)).rejects.toThrow('每张图片不能超过 20MB');
await expect(storage.store(upload(Buffer.from('not an image at all'), 'fake.png'))).rejects.toThrow('图片内容无效');
});
it('拒绝越界的图片标识', async () => {
for (const evil of ['../application.yml', '..%2Fapplication.yml', 'a/b.png', 'not-a-uuid.png', '../../etc/passwd']) {
await expect(storage.readOriginal(evil)).rejects.toThrow('图片标识无效');
}
});
it('只有原图没有缩略图时按需补生成', async () => {
const identifier = await storage.store(upload(await pngOf(400, 300))) as string;
// 模拟历史数据:缩略图缺失,只剩原图
await fs.unlink(join(root, `${identifier}.thumb.jpg`));
const thumbnail = await storage.readThumbnail(identifier);
expect((await sharp(thumbnail).metadata()).width).toBe(240);
// 补生成后应落盘,下次不必再算
expect(await fs.readdir(root)).toContain(`${identifier}.thumb.jpg`);
});
it('原图丢失时读取缩略图报图片不存在', async () => {
const identifier = await storage.store(upload(await pngOf(400, 300))) as string;
await fs.unlink(join(root, `${identifier}.thumb.jpg`));
await fs.unlink(join(root, identifier));
await expect(storage.readThumbnail(identifier)).rejects.toThrow('图片不存在');
});
it('清理时原图与缩略图一并删除', async () => {
const identifier = await storage.store(upload(await pngOf(300, 300))) as string;
await storage.cleanupNewFile(identifier);
expect(await fs.readdir(root)).toHaveLength(0);
});
});
/**
* 文件用途(白话):验证手机号的搜索匹配规则与身份校验。
* 关联文件:phone-asset.service.ts。
*
* 按输入位数变化的搜索规则从接口签名完全看不出来,
* 写成统一模糊匹配看似等价,实际会让「输入尾号查找」退化成乱匹配。
*/
import { PhoneAssetService } from './phone-asset.service';
function buildService() {
const prisma: any = {
as_phone_asset: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
as_asset_device: { findMany: jest.fn().mockResolvedValue([]) },
};
return { service: new PhoneAssetService(prisma), prisma };
}
const whereOf = (prisma: any) => prisma.as_phone_asset.findMany.mock.calls[0][0].where;
const validSave = (overrides: any = {}) => ({ phoneNumber: '13800138000', iccid: '89860000000000000001', realNameOwner: '张三', ...overrides });
describe('PhoneAssetService 号码搜索', () => {
it('输入 3 位按号段前缀匹配', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '138' });
expect(whereOf(prisma).phone_number).toEqual({ startsWith: '138' });
});
it('输入 4 位按尾号匹配', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '8000' });
expect(whereOf(prisma).phone_number).toEqual({ endsWith: '8000' });
});
it('输入 7 位按前 3 位与后 4 位组合匹配', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '1388000' });
expect(whereOf(prisma).phone_number).toEqual({ startsWith: '138', endsWith: '8000' });
});
it('输入完整号码时精确匹配', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '13800138000' });
expect(whereOf(prisma).phone_number).toBe('13800138000');
});
it('其他长度按精确匹配处理', async () => {
const { service, prisma } = buildService();
await service.page({ phoneNumber: '13800' });
expect(whereOf(prisma).phone_number).toBe('13800');
});
it('不填号码时不附加号码条件', async () => {
const { service, prisma } = buildService();
await service.page({});
expect(whereOf(prisma).phone_number).toBeUndefined();
});
});
describe('PhoneAssetService 身份校验', () => {
it('缺少 ICCID 时拒绝', async () => {
const { service } = buildService();
await expect(service.create(validSave({ iccid: '' }))).rejects.toThrow('ICCID 不能为空');
});
it('缺少实名人时拒绝', async () => {
const { service } = buildService();
await expect(service.create(validSave({ realNameOwner: '' }))).rejects.toThrow('实名人不能为空');
});
it('虚拟号码豁免 ICCID 与实名人', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.create.mockResolvedValue({ id: 1n, phone_number: '13800138000' });
await expect(service.create({ phoneNumber: '13800138000', cardType: '虚拟号码' })).resolves.toBeDefined();
});
it('号码不是 11 位数字时拒绝', async () => {
const { service } = buildService();
for (const bad of ['1380013', '138001380000', 'abcdefghijk', '']) {
await expect(service.create(validSave({ phoneNumber: bad }))).rejects.toThrow('手机号必须是 11 位数字');
}
});
it('剥掉 +86 前缀后再校验', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.create.mockResolvedValue({ id: 1n, phone_number: '13800138000' });
await service.create(validSave({ phoneNumber: '+8613800138000' }));
expect(prisma.as_phone_asset.create.mock.calls[0][0].data.phone_number).toBe('13800138000');
});
it('处置状态不填时默认正常使用', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.create.mockResolvedValue({ id: 1n, phone_number: '13800138000' });
await service.create(validSave());
expect(prisma.as_phone_asset.create.mock.calls[0][0].data.disposal_status).toBe('正常使用');
});
it('手动建档固定标记为自有号码', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.create.mockResolvedValue({ id: 1n, phone_number: '13800138000' });
await service.create(validSave());
// 企微自动录入的号会标成 EXTERNAL 以便追溯,手动建的必须是 SELF
expect(prisma.as_phone_asset.create.mock.calls[0][0].data.number_type).toBe('SELF');
});
it('记录不存在时提示明确原因', async () => {
const { service, prisma } = buildService();
prisma.as_phone_asset.findFirst.mockResolvedValue(null);
await expect(service.softDelete(999)).rejects.toThrow('手机号码管理不存在或已删除');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment