feat(contracts): share notification, organization and device capability models
Move the Hub channel table, organization tags and device capability definitions into the workspace packages so the control plane and the console validate the same contract, and refresh the frozen upstream evidence for the new paths.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_SPECS,
|
||||
NotificationChannelConfigError,
|
||||
isNotificationChannelType,
|
||||
notificationChannelDefaults,
|
||||
notificationChannelFields,
|
||||
notificationChannelLabel,
|
||||
notificationChannelSecretKeys,
|
||||
notificationChannelSpec,
|
||||
parseKeyValueField,
|
||||
splitNotificationChannelConfig,
|
||||
} from './notifications.js';
|
||||
|
||||
describe('notification channel specs', () => {
|
||||
it('describes every Hub channel type exactly once', () => {
|
||||
const types = NOTIFICATION_CHANNEL_SPECS.map((spec) => spec.type);
|
||||
expect(new Set(types).size).toBe(types.length);
|
||||
expect(types).toEqual([
|
||||
'webhook',
|
||||
'bark',
|
||||
'pushplus',
|
||||
'wecom_app',
|
||||
'wecom_robot',
|
||||
'dingtalk_robot',
|
||||
'dingtalk_app',
|
||||
'feishu_robot',
|
||||
'telegram',
|
||||
'email',
|
||||
'serverchan',
|
||||
]);
|
||||
expect(NOTIFICATION_CHANNEL_SPECS.every((spec) => spec.summary.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps field keys unique and every field kind renderable', () => {
|
||||
for (const spec of NOTIFICATION_CHANNEL_SPECS) {
|
||||
const keys = spec.fields.map((field) => field.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
for (const field of spec.fields) {
|
||||
expect([
|
||||
'text',
|
||||
'url',
|
||||
'secret',
|
||||
'number',
|
||||
'select',
|
||||
'toggle',
|
||||
'key-values',
|
||||
] as const).toContain(field.kind);
|
||||
expect(field.label.length).toBeGreaterThan(0);
|
||||
if (field.kind === 'select') expect((field.options ?? []).length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('looks specs up by type and tolerates unknown input', () => {
|
||||
expect(notificationChannelSpec('telegram')?.label).toBe('Telegram');
|
||||
expect(notificationChannelSpec('nope')).toBeUndefined();
|
||||
expect(isNotificationChannelType('bark')).toBe(true);
|
||||
expect(isNotificationChannelType('Bark')).toBe(false);
|
||||
expect(notificationChannelLabel('nope')).toBe('nope');
|
||||
expect(notificationChannelFields('nope')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitNotificationChannelConfig', () => {
|
||||
it('fills defaults and separates secrets from plain config', () => {
|
||||
const { config, secrets } = splitNotificationChannelConfig('bark', {
|
||||
server_url: 'https://bark.example.test',
|
||||
device_key: ' key-1 ',
|
||||
group: 'Island',
|
||||
});
|
||||
expect(config).toEqual({
|
||||
server_url: 'https://bark.example.test',
|
||||
group: 'Island',
|
||||
sound: '',
|
||||
level: '',
|
||||
icon: '',
|
||||
auto_copy: true,
|
||||
save_history: true,
|
||||
});
|
||||
expect(secrets).toEqual({ device_key: 'key-1' });
|
||||
expect(notificationChannelSecretKeys('bark')).toEqual(['device_key']);
|
||||
});
|
||||
|
||||
it('carries a stored secret forward when the editor leaves it blank', () => {
|
||||
const { config, secrets } = splitNotificationChannelConfig(
|
||||
'telegram',
|
||||
{ bot_token: '', chat_id: '42' },
|
||||
{ bot_token: 'stored-token' },
|
||||
);
|
||||
expect(secrets).toEqual({ bot_token: 'stored-token' });
|
||||
expect(config['chat_id']).toBe('42');
|
||||
});
|
||||
|
||||
it('names the missing required fields in the local language', () => {
|
||||
expect(() => splitNotificationChannelConfig('serverchan', {})).toThrowError(
|
||||
NotificationChannelConfigError,
|
||||
);
|
||||
try {
|
||||
splitNotificationChannelConfig('serverchan', {});
|
||||
} catch (error) {
|
||||
const failure = error as NotificationChannelConfigError;
|
||||
expect(failure.fields).toEqual(['send_key']);
|
||||
expect(failure.message).toContain('SendKey');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates numeric ranges and select membership', () => {
|
||||
const relay = {
|
||||
smtp_host: 'smtp.example.test',
|
||||
sender_address: 'a@example.test',
|
||||
receiver_addresses: 'b@example.test',
|
||||
};
|
||||
expect(() => splitNotificationChannelConfig('email', { ...relay, smtp_port: 99_999 })).toThrow(
|
||||
/SMTP 端口/u,
|
||||
);
|
||||
expect(() =>
|
||||
splitNotificationChannelConfig('bark', { device_key: 'k', level: 'loud' }),
|
||||
).toThrow(/推送级别/u);
|
||||
expect(
|
||||
splitNotificationChannelConfig('email', {
|
||||
...relay,
|
||||
smtp_port: '587',
|
||||
smtp_security: 'starttls',
|
||||
}).config['smtp_port'],
|
||||
).toBe(587);
|
||||
});
|
||||
|
||||
it('rejects unknown channel types instead of guessing a schema', () => {
|
||||
expect(() => splitNotificationChannelConfig('sms' as never, {} as never)).toThrowError(
|
||||
NotificationChannelConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps declared defaults for untouched fields', () => {
|
||||
expect(notificationChannelDefaults('webhook')).toEqual({
|
||||
url: '',
|
||||
http_method: 'POST',
|
||||
headers: '',
|
||||
});
|
||||
expect(notificationChannelFields('webhook').map((field) => field.kind)).toEqual([
|
||||
'url',
|
||||
'select',
|
||||
'secret',
|
||||
'key-values',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseKeyValueField', () => {
|
||||
it('reads colon lines and ignores anything malformed', () => {
|
||||
expect(parseKeyValueField('X-A: 1\n\nno-separator\nX-B: a/b \n:c-empty')).toEqual({
|
||||
'X-A': '1',
|
||||
'X-B': 'a/b',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns nothing for non-text input', () => {
|
||||
expect(parseKeyValueField(undefined)).toEqual({});
|
||||
expect(parseKeyValueField({})).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user