1845 lines
60 KiB
TypeScript
1845 lines
60 KiB
TypeScript
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
systemConfig: {
|
||
findUnique: jest.fn(),
|
||
upsert: jest.fn(),
|
||
},
|
||
},
|
||
}));
|
||
|
||
jest.mock('../license/fingerprint-service', () => ({
|
||
generateFingerprint: jest.fn().mockResolvedValue({
|
||
machineId: 'machine-1',
|
||
hostname: 'nas-1',
|
||
macAddress: 'aa:bb:cc:dd:ee:ff',
|
||
instanceId: 'instance-1',
|
||
}),
|
||
}));
|
||
|
||
jest.mock('../license/license-service', () => ({
|
||
getLocalLicense: jest.fn(),
|
||
isLicenseConfigExpired: jest.fn(),
|
||
}));
|
||
|
||
jest.mock('../license/crypto-service', () => ({
|
||
encryptLocal: jest.fn((data) => `encrypted:${data}`),
|
||
decryptLocal: jest.fn((data) => data.replace('encrypted:', '')),
|
||
hashData: jest.fn((data) => (data === 'module-entitlement' ? 'hash' : 'unknown-hash')),
|
||
verifySignature: jest.fn(),
|
||
}));
|
||
|
||
jest.mock('../license/request-signature', () => ({
|
||
buildSignedLicenseRequest: jest.fn((action, payload) => ({
|
||
...payload,
|
||
timestamp: 1779753600000,
|
||
nonce: 'nonce',
|
||
signature: `signature:${action}`,
|
||
})),
|
||
}));
|
||
|
||
jest.mock('../license/server-config', () => ({
|
||
buildLicenseServerLicenseApiUrl: jest.fn((action) => `https://license.test/api/license/flnxi/${action}`),
|
||
getLicenseServerUrl: jest.fn(() => 'https://license.test'),
|
||
getLicenseProjectCode: jest.fn(() => 'flnxi'),
|
||
}));
|
||
|
||
import { prisma } from '@/lib/prisma';
|
||
import { verifySignature } from '../license/crypto-service';
|
||
import { getLocalLicense, isLicenseConfigExpired } from '../license/license-service';
|
||
|
||
const mockFetch = jest.fn();
|
||
global.fetch = mockFetch;
|
||
|
||
const mockVerifySignature = verifySignature as jest.MockedFunction<typeof verifySignature>;
|
||
const mockGetLocalLicense = getLocalLicense as jest.MockedFunction<typeof getLocalLicense>;
|
||
const mockIsLicenseConfigExpired = isLicenseConfigExpired as jest.MockedFunction<typeof isLicenseConfigExpired>;
|
||
|
||
const paidCoreArtifact = {
|
||
type: 'registry-image' as const,
|
||
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
|
||
digest: null,
|
||
version: '1.0.0',
|
||
};
|
||
|
||
function mockPullCredential(overrides: Partial<Record<string, unknown>> = {}) {
|
||
return {
|
||
registryHost: 'registry.test',
|
||
repository: 'flnxi/premium/paid-core',
|
||
tag: '1.0.0',
|
||
digest: null,
|
||
username: 'flnxi-module',
|
||
pullToken: 'pull-token:paid-core',
|
||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function mockPaidCoreManifestModule(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
id: 'paid-core',
|
||
minLicenseType: 'STANDARD',
|
||
artifact: paidCoreArtifact,
|
||
sizeBytes: 18,
|
||
sha256: 'hash',
|
||
entitlementVersion: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function encryptedConfig(value: unknown) {
|
||
return `encrypted:${JSON.stringify(value)}`;
|
||
}
|
||
|
||
function mockLicense(type: 'FREE' | 'STANDARD' | 'PRO') {
|
||
mockGetLocalLicense.mockResolvedValue({
|
||
instanceId: 'instance-1',
|
||
licenseKey: `FLNXI-${type}-TEST`,
|
||
licenseData: 'encrypted-license',
|
||
lastVerified: new Date().toISOString(),
|
||
cachedConfig: {
|
||
type,
|
||
limits: type === 'FREE' ? { maxUsers: 10, maxMaterials: 100 } : { maxUsers: -1, maxMaterials: -1 },
|
||
features: {
|
||
customTemplates: type !== 'FREE',
|
||
systemSettings: type !== 'FREE',
|
||
departmentIsolation: type !== 'FREE',
|
||
apiAccess: type !== 'FREE',
|
||
intranetPenetration: type === 'PRO',
|
||
},
|
||
expiresAt: null,
|
||
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||
},
|
||
} as any);
|
||
mockIsLicenseConfigExpired.mockReturnValue(false);
|
||
}
|
||
|
||
function mockCachedPaidCore(params: {
|
||
withEntitlement?: boolean;
|
||
runtimeStatus?: 'missing' | 'pulling' | 'running' | 'unhealthy' | 'expired' | 'revoked';
|
||
pullCredential?: ReturnType<typeof mockPullCredential> | null;
|
||
}) {
|
||
(prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => {
|
||
if (where.key === 'premium_module_manifest') {
|
||
return Promise.resolve({
|
||
value: encryptedConfig({
|
||
manifest: {
|
||
version: 1,
|
||
licenseType: 'PRO',
|
||
instanceId: 'instance-1',
|
||
modules: [mockPaidCoreManifestModule()],
|
||
issuedAt: new Date().toISOString(),
|
||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||
},
|
||
signature: 'valid-signature',
|
||
syncedAt: new Date().toISOString(),
|
||
}),
|
||
});
|
||
}
|
||
|
||
if (where.key === 'premium_module_entitlements' && params.withEntitlement) {
|
||
return Promise.resolve({
|
||
value: encryptedConfig({
|
||
entitlements: {
|
||
'paid-core': {
|
||
entitlementBase64: 'module-entitlement',
|
||
sha256: 'hash',
|
||
sizeBytes: 18,
|
||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||
artifact: paidCoreArtifact,
|
||
pullCredential: params.pullCredential === undefined ? mockPullCredential() : params.pullCredential,
|
||
syncedAt: new Date().toISOString(),
|
||
},
|
||
},
|
||
}),
|
||
});
|
||
}
|
||
|
||
if (where.key === 'premium_module_runtime_status' && params.runtimeStatus) {
|
||
return Promise.resolve({
|
||
value: encryptedConfig({
|
||
'paid-core': {
|
||
moduleId: 'paid-core',
|
||
image: paidCoreArtifact.image,
|
||
digest: null,
|
||
status: params.runtimeStatus,
|
||
lastSyncAt: new Date().toISOString(),
|
||
},
|
||
}),
|
||
});
|
||
}
|
||
|
||
return Promise.resolve(null);
|
||
});
|
||
}
|
||
|
||
describe('premium-module-service', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
mockFetch.mockReset();
|
||
mockVerifySignature.mockReturnValue(true);
|
||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
||
delete process.env.PAID_CORE_MODULE_URL;
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete process.env.PAID_CORE_MODULE_URL;
|
||
});
|
||
|
||
it('免费版只同步空模块清单,不拉取 paid-core', async () => {
|
||
mockLicense('FREE');
|
||
const manifestExpiresAt = new Date(Date.now() + 60_000).toISOString();
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
success: true,
|
||
manifest: {
|
||
version: 1,
|
||
licenseType: 'FREE',
|
||
instanceId: 'instance-1',
|
||
modules: [],
|
||
issuedAt: new Date().toISOString(),
|
||
expiresAt: manifestExpiresAt,
|
||
},
|
||
signature: 'valid-signature',
|
||
}),
|
||
});
|
||
|
||
const { syncPremiumModules } = await import('../license/premium-module-service');
|
||
const result = await syncPremiumModules();
|
||
|
||
expect(result).toEqual({ synced: true });
|
||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
'https://license.test/api/license/flnxi/premium-modules',
|
||
expect.objectContaining({ method: 'POST' })
|
||
);
|
||
expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it('付费版应拉取 paid-core 授权凭证和短期拉包凭证', async () => {
|
||
mockLicense('PRO');
|
||
const manifestExpiresAt = new Date(Date.now() + 60_000).toISOString();
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
success: true,
|
||
manifest: {
|
||
version: 1,
|
||
licenseType: 'PRO',
|
||
instanceId: 'instance-1',
|
||
modules: [mockPaidCoreManifestModule()],
|
||
issuedAt: new Date().toISOString(),
|
||
expiresAt: manifestExpiresAt,
|
||
},
|
||
signature: 'valid-signature',
|
||
}),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
success: true,
|
||
entitlement: {
|
||
moduleId: 'paid-core',
|
||
entitlementBase64: 'module-entitlement',
|
||
sha256: 'hash',
|
||
sizeBytes: 18,
|
||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||
artifact: paidCoreArtifact,
|
||
},
|
||
}),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
success: true,
|
||
pullCredential: mockPullCredential(),
|
||
}),
|
||
});
|
||
|
||
const { syncPremiumModules } = await import('../license/premium-module-service');
|
||
const result = await syncPremiumModules();
|
||
|
||
expect(result).toEqual({ synced: true });
|
||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||
2,
|
||
'https://license.test/api/license/flnxi/premium-modules/paid-core',
|
||
expect.objectContaining({ method: 'POST' })
|
||
);
|
||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||
3,
|
||
'https://license.test/api/license/flnxi/premium-modules/paid-core/pull-token',
|
||
expect.objectContaining({ method: 'POST' })
|
||
);
|
||
expect(JSON.parse(mockFetch.mock.calls[1][1].body).manifestExpiresAt).toBe(manifestExpiresAt);
|
||
expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it('没有有效授权凭证时不应视为已拥有 paid-core', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: false });
|
||
|
||
const { hasPremiumModule } = await import('../license/premium-module-service');
|
||
|
||
await expect(hasPremiumModule('paid-core')).resolves.toBe(false);
|
||
});
|
||
|
||
it('清单和本地授权凭证都有效时应视为已拥有 paid-core', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true });
|
||
|
||
const { hasPremiumModule } = await import('../license/premium-module-service');
|
||
|
||
await expect(hasPremiumModule('paid-core')).resolves.toBe(true);
|
||
});
|
||
|
||
it('缓存中的短期拉包凭证有效时才返回给 Agent', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, pullCredential: mockPullCredential() });
|
||
|
||
const { getCachedModulePullCredential } = await import('../license/premium-module-service');
|
||
|
||
await expect(getCachedModulePullCredential('paid-core')).resolves.toEqual(
|
||
expect.objectContaining({
|
||
username: 'flnxi-module',
|
||
repository: 'flnxi/premium/paid-core',
|
||
})
|
||
);
|
||
});
|
||
|
||
it('过期的短期拉包凭证不应返回给 Agent', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({
|
||
withEntitlement: true,
|
||
pullCredential: mockPullCredential({ expiresAt: new Date(Date.now() - 1_000).toISOString() }),
|
||
});
|
||
|
||
const { getCachedModulePullCredential } = await import('../license/premium-module-service');
|
||
|
||
await expect(getCachedModulePullCredential('paid-core')).resolves.toBeNull();
|
||
});
|
||
|
||
it('paid-core 未处于运行态时不允许使用受控能力', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'missing' });
|
||
|
||
const { authorizePremiumModuleAction } = await import('../license/premium-module-service');
|
||
|
||
await expect(authorizePremiumModuleAction('paid-core', 'customTemplates:use')).resolves.toEqual(
|
||
expect.objectContaining({
|
||
available: false,
|
||
moduleId: 'paid-core',
|
||
action: 'customTemplates:use',
|
||
status: 'missing',
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行且授权接口放行时允许使用受控能力', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', allowed: true }),
|
||
});
|
||
|
||
const { authorizePremiumModuleAction } = await import('../license/premium-module-service');
|
||
|
||
await expect(authorizePremiumModuleAction('paid-core', 'customTemplates:use')).resolves.toEqual(
|
||
expect.objectContaining({
|
||
available: true,
|
||
moduleId: 'paid-core',
|
||
action: 'customTemplates:use',
|
||
status: 'running',
|
||
})
|
||
);
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/authorize',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"action":"customTemplates:use"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块执行自定义模板字段校验和格式化', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
fields: [
|
||
{
|
||
id: null,
|
||
key: 'brandName',
|
||
label: '品牌',
|
||
type: 'text',
|
||
required: false,
|
||
orderIndex: 0,
|
||
layoutWidth: 'full',
|
||
validations: null,
|
||
optionsSource: null,
|
||
optionsStatic: null,
|
||
allowedExtensions: null,
|
||
visibleOnHomeFilter: false,
|
||
isSearchable: false,
|
||
isReadOnly: false,
|
||
addToCategory: false,
|
||
conditions: null,
|
||
},
|
||
],
|
||
}),
|
||
});
|
||
|
||
const { validateUploadTemplateFieldsWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await validateUploadTemplateFieldsWithPaidCore([
|
||
{ key: 'brandName', label: '品牌', type: 'text' },
|
||
]);
|
||
|
||
expect(result.valid).toBe(true);
|
||
if (result.valid) {
|
||
expect(result.fields[0].key).toBe('brandName');
|
||
}
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/validate-fields',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"brandName"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块校验素材提交的自定义模板字段内容', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
templateName: '自定义模板',
|
||
templateFields: {
|
||
customCode: 'CODE-001',
|
||
},
|
||
visibleFieldKeys: ['customCode'],
|
||
}),
|
||
});
|
||
|
||
const { validateSubmittedTemplateFieldsWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await validateSubmittedTemplateFieldsWithPaidCore({
|
||
templateId: 'template-1',
|
||
templateName: '自定义模板',
|
||
fields: [
|
||
{
|
||
key: 'customCode',
|
||
label: '自定义编码',
|
||
type: 'text',
|
||
required: true,
|
||
validations: JSON.stringify({ pattern: '^CODE-' }),
|
||
},
|
||
],
|
||
templateFields: {
|
||
customCode: 'CODE-001',
|
||
},
|
||
allowUnknownKeys: false,
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
templateName: '自定义模板',
|
||
templateFields: {
|
||
customCode: 'CODE-001',
|
||
},
|
||
visibleFieldKeys: ['customCode'],
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/materials/validate-template-fields',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"templateId":"template-1"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 拒绝素材自定义字段内容时应透传错误码和详情', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 400,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: false,
|
||
error: '模板必填字段未填写:自定义编码',
|
||
errorCode: 'TEMPLATE_REQUIRED_FIELDS_MISSING',
|
||
details: {
|
||
missingRequiredFields: [{ key: 'customCode', label: '自定义编码', type: 'text' }],
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { validateSubmittedTemplateFieldsWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await validateSubmittedTemplateFieldsWithPaidCore({
|
||
templateId: 'template-1',
|
||
templateName: '自定义模板',
|
||
fields: [{ key: 'customCode', label: '自定义编码', type: 'text', required: true }],
|
||
templateFields: {},
|
||
allowUnknownKeys: false,
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: false,
|
||
error: '模板必填字段未填写:自定义编码',
|
||
errorCode: 'TEMPLATE_REQUIRED_FIELDS_MISSING',
|
||
details: {
|
||
missingRequiredFields: [{ key: 'customCode', label: '自定义编码', type: 'text' }],
|
||
},
|
||
});
|
||
});
|
||
|
||
it('paid-core 运行时应由模块准备自定义模板素材 metadata', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
standardData: { productModel: 'MX-001' },
|
||
metadata: JSON.stringify({
|
||
templateId: 'template-1',
|
||
templateFields: { customCode: 'CODE-001' },
|
||
}),
|
||
metadataSize: { ok: true, size: 72, maxSize: 65535 },
|
||
}),
|
||
});
|
||
|
||
const { prepareMaterialMetadataWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await prepareMaterialMetadataWithPaidCore({
|
||
templateId: 'template-1',
|
||
templateFields: {
|
||
productModel: 'MX-001',
|
||
customCode: 'CODE-001',
|
||
},
|
||
previewUrls: ['s3://bucket/preview.webp'],
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
if (result.valid) {
|
||
expect(result.standardData.productModel).toBe('MX-001');
|
||
expect(result.metadata).toContain('customCode');
|
||
expect(result.metadataSize.ok).toBe(true);
|
||
}
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/materials/prepare-metadata',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"templateId":"template-1"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 拒绝超限 metadata 时应透传错误码和详情', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 400,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: false,
|
||
error: '自定义字段数据过大',
|
||
errorCode: 'METADATA_TOO_LARGE',
|
||
details: { size: 70000, maxSize: 65535 },
|
||
}),
|
||
});
|
||
|
||
const { prepareMaterialMetadataWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await prepareMaterialMetadataWithPaidCore({
|
||
templateId: 'template-1',
|
||
templateFields: { customCode: 'x'.repeat(70_000) },
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: false,
|
||
error: '自定义字段数据过大',
|
||
errorCode: 'METADATA_TOO_LARGE',
|
||
details: { size: 70000, maxSize: 65535 },
|
||
});
|
||
});
|
||
|
||
it('paid-core 运行时应由模块准备模板迁移 metadata', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', allowed: true }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
standardData: { productModel: 'MX-001' },
|
||
metadata: JSON.stringify({
|
||
templateId: 'template-target',
|
||
templateFields: { customCode: 'CODE-001' },
|
||
templateMigrationBackups: [
|
||
{
|
||
sourceTemplateId: 'template-source',
|
||
targetTemplateId: 'template-target',
|
||
droppedTemplateFields: { oldOnlyField: '旧值' },
|
||
},
|
||
],
|
||
}),
|
||
metadataSize: { ok: true, size: 180, maxSize: 65535 },
|
||
droppedTemplateFieldCount: 1,
|
||
droppedStandardFieldCount: 1,
|
||
droppedTemplateFields: { oldOnlyField: '旧值' },
|
||
droppedStandardFields: { categoryName: '旧分类' },
|
||
}),
|
||
});
|
||
|
||
const { prepareTemplateMigrationMetadataWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await prepareTemplateMigrationMetadataWithPaidCore({
|
||
sourceTemplateId: 'template-source',
|
||
sourceTemplateName: '旧模板',
|
||
targetTemplateId: 'template-target',
|
||
targetTemplateName: '新模板',
|
||
migratedAt: '2026-06-03T00:00:00.000Z',
|
||
targetFieldKeys: ['productModel', 'customCode'],
|
||
templateFields: {
|
||
productModel: 'MX-001',
|
||
customCode: 'CODE-001',
|
||
},
|
||
materialStandardFields: {
|
||
productModel: 'MX-001',
|
||
categoryName: '旧分类',
|
||
},
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
if (result.valid) {
|
||
expect(result.standardData.productModel).toBe('MX-001');
|
||
expect(result.droppedTemplateFieldCount).toBe(1);
|
||
expect(result.droppedStandardFields.categoryName).toBe('旧分类');
|
||
expect(result.metadata).toContain('templateMigrationBackups');
|
||
}
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/prepare-migration-metadata',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"targetTemplateId":"template-target"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块生成模板迁移预览和备份统计', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
migrationBackup: {
|
||
materialsWithDroppedFields: 1,
|
||
droppedTemplateFieldCount: 1,
|
||
droppedStandardFieldCount: 1,
|
||
},
|
||
migrationPreview: {
|
||
totalMaterialCount: 5,
|
||
totalMaterials: 1,
|
||
previewLimited: true,
|
||
previewLimit: 1,
|
||
targetCurrentFields: [
|
||
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
|
||
],
|
||
backupFields: {
|
||
templateFields: [
|
||
{
|
||
key: 'oldOnlyField',
|
||
label: '旧字段',
|
||
type: 'text',
|
||
source: 'template',
|
||
affectedCount: 1,
|
||
},
|
||
],
|
||
standardFields: [
|
||
{
|
||
key: 'categoryName',
|
||
label: '分类',
|
||
source: 'standard',
|
||
affectedCount: 1,
|
||
},
|
||
],
|
||
},
|
||
samples: [
|
||
{
|
||
materialId: 'mat-1',
|
||
productModel: 'MX-001',
|
||
droppedTemplateFields: [
|
||
{ key: 'oldOnlyField', label: '旧字段', type: 'text', source: 'template' },
|
||
],
|
||
droppedStandardFields: [
|
||
{ key: 'categoryName', label: '分类', source: 'standard' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { buildTemplateMigrationPreviewWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await buildTemplateMigrationPreviewWithPaidCore({
|
||
totalMaterialCount: 5,
|
||
migratableCount: 1,
|
||
previewOnly: true,
|
||
previewLimit: 1,
|
||
targetFields: [{ key: 'productModel', label: '产品型号', type: 'text', required: false }],
|
||
sourceTemplateFields: [{ key: 'oldOnlyField', label: '旧字段', type: 'text', required: false }],
|
||
impacts: [
|
||
{
|
||
materialId: 'mat-1',
|
||
productModel: 'MX-001',
|
||
droppedTemplateFieldKeys: ['oldOnlyField'],
|
||
droppedStandardFieldKeys: ['categoryName'],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
if (result.valid) {
|
||
expect(result.migrationBackup.materialsWithDroppedFields).toBe(1);
|
||
expect(result.migrationPreview.previewLimited).toBe(true);
|
||
expect(result.migrationPreview.backupFields.templateFields[0].key).toBe('oldOnlyField');
|
||
expect(result.migrationPreview.samples[0].materialId).toBe('mat-1');
|
||
}
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/build-migration-preview',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"droppedTemplateFieldKeys":["oldOnlyField"]'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划模板迁移批处理', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 409,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
allowed: false,
|
||
statusCode: 409,
|
||
error: '该模板当前有 300 个素材,超过默认安全迁移数量 200。请先预览迁移影响,确认备份后再执行。',
|
||
errorCode: 'MIGRATION_CONFIRM_REQUIRED',
|
||
details: {
|
||
totalMaterialCount: 300,
|
||
safeLimit: 200,
|
||
migrationLimit: 300,
|
||
maxBatchLimit: 1000,
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { planTemplateMigrationBatchWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planTemplateMigrationBatchWithPaidCore({
|
||
sourceTemplateId: 'template-source',
|
||
sourceTemplateName: '旧模板',
|
||
targetTemplateId: 'template-target',
|
||
targetTemplateName: '新模板',
|
||
totalMaterialCount: 300,
|
||
request: {
|
||
migrationLimit: 300,
|
||
},
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
allowed: false,
|
||
statusCode: 409,
|
||
error: '该模板当前有 300 个素材,超过默认安全迁移数量 200。请先预览迁移影响,确认备份后再执行。',
|
||
errorCode: 'MIGRATION_CONFIRM_REQUIRED',
|
||
response: undefined,
|
||
details: {
|
||
totalMaterialCount: 300,
|
||
safeLimit: 200,
|
||
migrationLimit: 300,
|
||
maxBatchLimit: 1000,
|
||
},
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/plan-migration-batch',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"totalMaterialCount":300'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块生成公开模板列表和素材类型投影', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
templates: [
|
||
{ id: 'builtin-other-material', name: '其他素材模板', isBuiltin: true },
|
||
{ id: 'custom-template', name: '客户模板素材模板', isBuiltin: false },
|
||
],
|
||
uploadTemplates: [
|
||
{ id: 'builtin-other-material', name: '其他素材模板', isBuiltin: true, materialCount: 3 },
|
||
{
|
||
id: 'custom-template',
|
||
name: '客户模板素材模板',
|
||
isBuiltin: false,
|
||
materialCount: 2,
|
||
fields: [{ key: 'customCode', label: '编码', isReadOnly: false }],
|
||
},
|
||
],
|
||
materialTypes: ['其他', 'template:custom-template:客户模板'],
|
||
}),
|
||
});
|
||
|
||
const { projectPublicTemplateOptionsWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await projectPublicTemplateOptionsWithPaidCore({
|
||
builtinTemplates: [{ id: 'builtin-other-material', name: '其他素材模板' }],
|
||
customTemplates: [
|
||
{
|
||
id: 'custom-template',
|
||
name: '客户模板素材模板',
|
||
status: 'active',
|
||
fields: [
|
||
{ key: 'customCode', label: '编码', isReadOnly: false },
|
||
{ key: 'materialType', label: '素材类型', isReadOnly: false },
|
||
],
|
||
},
|
||
],
|
||
materialCounts: {
|
||
'builtin-other-material': 3,
|
||
'custom-template': 2,
|
||
},
|
||
builtinMaterialTypes: ['其他'],
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
if (result.valid) {
|
||
expect(result.templates).toHaveLength(2);
|
||
expect(result.uploadTemplates[1]).toMatchObject({ id: 'custom-template', materialCount: 2 });
|
||
expect(result.materialTypes).toContain('template:custom-template:客户模板');
|
||
}
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/project-public-options',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"builtinMaterialTypes":["其他"]'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块计算自定义模板字段填写率', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
fieldStats: {
|
||
totalMaterials: 2,
|
||
fields: [
|
||
{
|
||
fieldKey: 'brandName',
|
||
fieldLabel: '品牌',
|
||
fieldType: 'text',
|
||
filledCount: 1,
|
||
totalCount: 2,
|
||
fillRate: 50,
|
||
isLowFillRate: false,
|
||
},
|
||
],
|
||
lowFillRateFields: [],
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { calculateTemplateFieldStatsWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await calculateTemplateFieldStatsWithPaidCore({
|
||
fields: [{ key: 'brandName', label: '品牌', type: 'text' }],
|
||
materials: [
|
||
{ id: 'mat-1', metadata: JSON.stringify({ templateFields: { brandName: 'FLNXI' } }) },
|
||
{ id: 'mat-2', metadata: JSON.stringify({ templateFields: { brandName: '' } }) },
|
||
],
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
if (result.valid) {
|
||
expect(result.fieldStats?.totalMaterials).toBe(2);
|
||
expect(result.fieldStats?.fields[0]).toMatchObject({
|
||
fieldKey: 'brandName',
|
||
fillRate: 50,
|
||
});
|
||
}
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/calculate-field-stats',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"fields":[{"key":"brandName"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划素材列表自定义字段搜索', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
search: 'CODE-001',
|
||
customFieldKeys: ['customCode'],
|
||
}),
|
||
});
|
||
|
||
const { planMaterialCustomSearchWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planMaterialCustomSearchWithPaidCore({
|
||
search: 'CODE-001',
|
||
searchableFields: [
|
||
{ key: 'customCode' },
|
||
{ key: 'productModel' },
|
||
],
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
search: 'CODE-001',
|
||
customFieldKeys: ['customCode'],
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/materials/plan-custom-search',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"searchableFields":[{"key":"customCode"},{"key":"productModel"}]'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划素材列表查询条件', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
search: 'CODE-001',
|
||
standardSearchFields: ['productModel', 'brandName'],
|
||
customSearchFieldKeys: ['customCode'],
|
||
standardHomeFilters: [{ key: 'brandName', value: 'FLNXI' }],
|
||
customHomeFilters: [{ key: 'customCode', value: 'CODE-001' }],
|
||
}),
|
||
});
|
||
|
||
const { planMaterialListQueryWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planMaterialListQueryWithPaidCore({
|
||
search: 'CODE-001',
|
||
searchableFields: [{ key: 'customCode' }, { key: 'productModel' }],
|
||
homeFieldFilters: [
|
||
{ key: 'brandName', value: 'FLNXI' },
|
||
{ key: 'customCode', value: 'CODE-001' },
|
||
],
|
||
enabledHomeFilterFields: [{ key: 'customCode' }],
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
search: 'CODE-001',
|
||
standardSearchFields: ['productModel', 'brandName'],
|
||
customSearchFieldKeys: ['customCode'],
|
||
standardHomeFilters: [{ key: 'brandName', value: 'FLNXI' }],
|
||
customHomeFilters: [{ key: 'customCode', value: 'CODE-001' }],
|
||
moduleAvailable: true,
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/materials/plan-list-query',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"homeFieldFilters"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划首页自定义筛选字段统计', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
customFieldKeys: ['customCode'],
|
||
}),
|
||
});
|
||
|
||
const { planHomeFilterCustomFieldCountsWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planHomeFilterCustomFieldCountsWithPaidCore({
|
||
fields: [
|
||
{ key: 'categoryName' },
|
||
{ key: 'customCode' },
|
||
],
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
customFieldKeys: ['customCode'],
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/materials/plan-home-filter-counts',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"fields":[{"key":"categoryName"},{"key":"customCode"}]'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划首页筛选字段统计来源', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
fieldPlans: [
|
||
{ key: 'categoryName', source: 'column' },
|
||
{ key: 'customCode', source: 'metadata' },
|
||
],
|
||
}),
|
||
});
|
||
|
||
const { planHomeFilterFieldCountsWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planHomeFilterFieldCountsWithPaidCore({
|
||
fields: [
|
||
{ key: 'categoryName' },
|
||
{ key: 'customCode' },
|
||
],
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
fieldPlans: [
|
||
{ key: 'categoryName', source: 'column' },
|
||
{ key: 'customCode', source: 'metadata' },
|
||
],
|
||
moduleAvailable: true,
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/materials/plan-home-filter-fields',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"customCode"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划部门隔离管理范围', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
scopedDepartments: ['设计部', '设计部-建模组'],
|
||
withinScope: true,
|
||
}),
|
||
});
|
||
|
||
const { planDepartmentScopeWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planDepartmentScopeWithPaidCore({
|
||
scopeDepartment: '设计部',
|
||
targetDepartment: '设计部-建模组',
|
||
departments: [
|
||
{ name: '设计部' },
|
||
{ name: '设计部-建模组' },
|
||
{ name: '运营部' },
|
||
],
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
scopedDepartments: ['设计部', '设计部-建模组'],
|
||
withinScope: true,
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/departments/plan-scope',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"scopeDepartment":"设计部"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划部门结构变更', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
allowed: true,
|
||
action: 'rename',
|
||
departmentOps: [
|
||
{ type: 'rename', id: 'dept-1', oldName: '设计部', newName: '产品部' },
|
||
],
|
||
userOps: [
|
||
{ type: 'update', id: 'user-1', data: { department: '产品部' } },
|
||
],
|
||
summary: {
|
||
renamedDepartmentCount: 1,
|
||
updatedUserCount: 1,
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { planDepartmentStructureChangeWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planDepartmentStructureChangeWithPaidCore({
|
||
action: 'rename',
|
||
target: { id: 'dept-1', name: '产品部' },
|
||
departments: [{ id: 'dept-1', name: '设计部' }],
|
||
users: [{ id: 'user-1', department: '设计部', managedDepartment: null }],
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
allowed: true,
|
||
action: 'rename',
|
||
departmentOps: [
|
||
{ type: 'rename', id: 'dept-1', oldName: '设计部', newName: '产品部' },
|
||
],
|
||
userOps: [
|
||
{ type: 'update', id: 'user-1', data: { department: '产品部' } },
|
||
],
|
||
summary: {
|
||
renamedDepartmentCount: 1,
|
||
updatedUserCount: 1,
|
||
},
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/departments/plan-structure-change',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"action":"rename"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 拒绝模板迁移超限 metadata 时应透传错误码和详情', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', allowed: true }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 400,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: false,
|
||
error: '模板迁移后 metadata 数据过大',
|
||
errorCode: 'METADATA_TOO_LARGE',
|
||
details: { size: 70000, maxSize: 65535 },
|
||
}),
|
||
});
|
||
|
||
const { prepareTemplateMigrationMetadataWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await prepareTemplateMigrationMetadataWithPaidCore({
|
||
sourceTemplateId: 'template-source',
|
||
sourceTemplateName: '旧模板',
|
||
targetTemplateId: 'template-target',
|
||
targetTemplateName: '新模板',
|
||
migratedAt: '2026-06-03T00:00:00.000Z',
|
||
targetFieldKeys: ['productModel'],
|
||
templateFields: { productModel: 'MX-001' },
|
||
materialStandardFields: { productModel: 'MX-001' },
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: false,
|
||
error: '模板迁移后 metadata 数据过大',
|
||
errorCode: 'METADATA_TOO_LARGE',
|
||
details: { size: 70000, maxSize: 65535 },
|
||
});
|
||
});
|
||
|
||
it('paid-core 拒绝模板迁移动作授权时不应继续准备迁移 metadata', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 403,
|
||
json: async () => ({ moduleId: 'paid-core', allowed: false, error: '授权模块拒绝迁移' }),
|
||
});
|
||
|
||
const { prepareTemplateMigrationMetadataWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await prepareTemplateMigrationMetadataWithPaidCore({
|
||
sourceTemplateId: 'template-source',
|
||
sourceTemplateName: '旧模板',
|
||
targetTemplateId: 'template-target',
|
||
targetTemplateName: '新模板',
|
||
migratedAt: '2026-06-03T00:00:00.000Z',
|
||
targetFieldKeys: ['productModel'],
|
||
targetFields: [{ key: 'productModel', label: '产品型号', type: 'text', required: false }],
|
||
materialFieldValues: { productModel: 'MX-001' },
|
||
materialStandardFields: { productModel: 'MX-001' },
|
||
});
|
||
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
valid: false,
|
||
errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAUTHORIZED',
|
||
})
|
||
);
|
||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/authorize',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('customTemplates:migrate'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 未运行时不得准备模板迁移 metadata', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'missing' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
|
||
const { prepareTemplateMigrationMetadataWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await prepareTemplateMigrationMetadataWithPaidCore({
|
||
sourceTemplateId: 'template-source',
|
||
sourceTemplateName: '旧模板',
|
||
targetTemplateId: 'template-target',
|
||
targetTemplateName: '新模板',
|
||
migratedAt: '2026-06-03T00:00:00.000Z',
|
||
targetFieldKeys: ['productModel'],
|
||
templateFields: { productModel: 'MX-001' },
|
||
materialStandardFields: { productModel: 'MX-001' },
|
||
});
|
||
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
valid: false,
|
||
errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAVAILABLE',
|
||
})
|
||
);
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('paid-core 运行时应由模块执行自定义模板保存请求标准化', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
mode: 'create',
|
||
fieldsProvided: true,
|
||
template: {
|
||
name: '成品图模板',
|
||
description: null,
|
||
status: 'draft',
|
||
materialType: '成品图',
|
||
materialKind: 'IMAGE',
|
||
fieldGroups: null,
|
||
displayConfig: null,
|
||
fields: [
|
||
{
|
||
id: null,
|
||
key: 'brandName',
|
||
label: '品牌',
|
||
type: 'text',
|
||
required: false,
|
||
orderIndex: 0,
|
||
layoutWidth: 'full',
|
||
validations: null,
|
||
optionsSource: null,
|
||
optionsStatic: null,
|
||
allowedExtensions: null,
|
||
visibleOnHomeFilter: false,
|
||
isSearchable: false,
|
||
isReadOnly: false,
|
||
addToCategory: false,
|
||
conditions: null,
|
||
},
|
||
],
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { normalizeUploadTemplateSaveWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await normalizeUploadTemplateSaveWithPaidCore(
|
||
{
|
||
name: ' 成品图模板 ',
|
||
materialType: '成品图',
|
||
materialKind: 'image',
|
||
fields: [{ key: 'brandName', label: '品牌', type: 'text' }],
|
||
},
|
||
'create'
|
||
);
|
||
|
||
expect(result.valid).toBe(true);
|
||
if (result.valid) {
|
||
expect(result.template).toEqual(
|
||
expect.objectContaining({
|
||
name: '成品图模板',
|
||
materialKind: 'IMAGE',
|
||
})
|
||
);
|
||
expect(result.fieldsProvided).toBe(true);
|
||
}
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/normalize-save',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"mode":"create"'),
|
||
})
|
||
);
|
||
expect(mockFetch.mock.calls.at(-1)?.[1]?.body).toContain('"payload"');
|
||
});
|
||
|
||
it('paid-core 保存标准化支持模板局部更新', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
mode: 'update',
|
||
fieldsProvided: false,
|
||
template: {
|
||
description: '只更新说明',
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { normalizeUploadTemplateSaveWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await normalizeUploadTemplateSaveWithPaidCore({ description: '只更新说明' }, 'update');
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
mode: 'update',
|
||
fieldsProvided: false,
|
||
template: {
|
||
description: '只更新说明',
|
||
},
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/normalize-save',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"mode":"update"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 未运行时不得执行模板保存标准化', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'missing' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
|
||
const { normalizeUploadTemplateSaveWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await normalizeUploadTemplateSaveWithPaidCore({ description: '只更新说明' }, 'update');
|
||
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
valid: false,
|
||
field: 'template',
|
||
})
|
||
);
|
||
if (!result.valid) {
|
||
expect(result.details).toEqual(expect.objectContaining({ moduleId: 'paid-core', status: 'missing' }));
|
||
}
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划自定义模板删除', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
allowed: false,
|
||
error: '无法删除模板"使用中的模板",因为有 5 个素材正在使用该模板。请先删除或迁移这些素材。',
|
||
errorCode: 'TEMPLATE_IN_USE',
|
||
details: {
|
||
templateName: '使用中的模板',
|
||
materialCount: 5,
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { planUploadTemplateDeleteWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planUploadTemplateDeleteWithPaidCore({
|
||
templateName: '使用中的模板',
|
||
materialCount: 5,
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
allowed: false,
|
||
error: '无法删除模板"使用中的模板",因为有 5 个素材正在使用该模板。请先删除或迁移这些素材。',
|
||
errorCode: 'TEMPLATE_IN_USE',
|
||
details: {
|
||
templateName: '使用中的模板',
|
||
materialCount: 5,
|
||
},
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/plan-delete',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"materialCount":5'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 未运行时不得规划自定义模板删除', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'missing' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
|
||
const { planUploadTemplateDeleteWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planUploadTemplateDeleteWithPaidCore({
|
||
templateName: '使用中的模板',
|
||
materialCount: 5,
|
||
});
|
||
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
valid: false,
|
||
allowed: false,
|
||
errorCode: 'PAID_CORE_TEMPLATE_DELETE_PLAN_UNAVAILABLE',
|
||
})
|
||
);
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('paid-core 运行时应由模块规划自定义模板更新', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
updateData: {
|
||
description: '更新说明',
|
||
version: 2,
|
||
},
|
||
fieldsProvided: false,
|
||
fields: null,
|
||
nextVersion: 2,
|
||
}),
|
||
});
|
||
|
||
const { planUploadTemplateUpdateWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planUploadTemplateUpdateWithPaidCore({
|
||
payload: { description: '更新说明', version: 1 },
|
||
existingTemplate: {
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '模板',
|
||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
materialCount: 0,
|
||
fields: [],
|
||
},
|
||
});
|
||
|
||
expect(result).toEqual({
|
||
valid: true,
|
||
updateData: {
|
||
description: '更新说明',
|
||
version: 2,
|
||
},
|
||
fieldsProvided: false,
|
||
fields: null,
|
||
nextVersion: 2,
|
||
});
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/plan-update',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"existingTemplate"'),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 更新规划拒绝结构变更时应透传状态码和详情', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 409,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: false,
|
||
error: '模板已有素材在使用',
|
||
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
|
||
field: 'template',
|
||
statusCode: 409,
|
||
details: {
|
||
templateName: '使用中的模板',
|
||
materialCount: 3,
|
||
dangerousChanges: [{ type: 'fieldDeleted', fieldId: 'field-1' }],
|
||
},
|
||
}),
|
||
});
|
||
|
||
const { planUploadTemplateUpdateWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await planUploadTemplateUpdateWithPaidCore({
|
||
payload: { version: 1, fields: [] },
|
||
existingTemplate: {
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '使用中的模板',
|
||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
materialCount: 3,
|
||
fields: [{
|
||
id: 'field-1',
|
||
key: 'productModel',
|
||
type: 'text',
|
||
label: '型号',
|
||
required: false,
|
||
addToCategory: false,
|
||
isSearchable: false,
|
||
isReadOnly: false,
|
||
validations: null,
|
||
optionsStatic: null,
|
||
conditions: null,
|
||
}],
|
||
},
|
||
});
|
||
|
||
expect(result).toEqual(
|
||
expect.objectContaining({
|
||
valid: false,
|
||
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
|
||
statusCode: 409,
|
||
details: expect.objectContaining({
|
||
materialCount: 3,
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('paid-core 拒绝保存标准化时应透传错误字段和详情', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 400,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: false,
|
||
error: '模板状态必须是 draft、active 或 archived',
|
||
field: 'status',
|
||
details: { allowedStatuses: ['draft', 'active', 'archived'] },
|
||
}),
|
||
});
|
||
|
||
const { normalizeUploadTemplateSaveWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await normalizeUploadTemplateSaveWithPaidCore({ status: 'invalid' }, 'update');
|
||
|
||
expect(result).toEqual({
|
||
valid: false,
|
||
error: '模板状态必须是 draft、active 或 archived',
|
||
field: 'status',
|
||
details: { allowedStatuses: ['draft', 'active', 'archived'] },
|
||
});
|
||
});
|
||
|
||
it('paid-core 运行时应由模块判断自定义模板结构变更风险', async () => {
|
||
mockLicense('PRO');
|
||
mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' });
|
||
process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test';
|
||
mockFetch
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ moduleId: 'paid-core', status: 'healthy' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
moduleId: 'paid-core',
|
||
valid: true,
|
||
changes: [
|
||
{
|
||
type: 'fieldDeleted',
|
||
fieldId: 'field-1',
|
||
fieldKey: 'brandName',
|
||
fieldLabel: '品牌',
|
||
},
|
||
],
|
||
}),
|
||
});
|
||
|
||
const { detectTemplateStructureChangesWithPaidCore } = await import('../license/premium-module-service');
|
||
const result = await detectTemplateStructureChangesWithPaidCore({
|
||
existingTemplate: {
|
||
materialType: 'image',
|
||
materialKind: 'IMAGE',
|
||
fields: [
|
||
{
|
||
id: 'field-1',
|
||
key: 'brandName',
|
||
label: '品牌',
|
||
type: 'text',
|
||
required: false,
|
||
addToCategory: false,
|
||
isSearchable: true,
|
||
isReadOnly: false,
|
||
validations: null,
|
||
optionsStatic: null,
|
||
conditions: null,
|
||
},
|
||
],
|
||
},
|
||
nextFields: [],
|
||
});
|
||
|
||
expect(result.valid).toBe(true);
|
||
if (result.valid) {
|
||
expect(result.changes[0]).toEqual(
|
||
expect.objectContaining({
|
||
type: 'fieldDeleted',
|
||
fieldKey: 'brandName',
|
||
})
|
||
);
|
||
}
|
||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||
'http://paid-core.test/templates/detect-structure-changes',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: expect.stringContaining('"existingTemplate"'),
|
||
})
|
||
);
|
||
});
|
||
});
|