test: 收紧消息推送前端契约
This commit is contained in:
parent
edb77bd67c
commit
c072d53554
@ -1,7 +1,70 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks.
|
||||||
|
import ts from 'typescript';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const requestClientGet = vi.fn();
|
const requestClientGet = vi.fn();
|
||||||
|
|
||||||
|
const messagePushMenuNames = [
|
||||||
|
'QqBotMessageSubscription',
|
||||||
|
'QqBotMessageTemplate',
|
||||||
|
'QqBotMessageSubscriptionList',
|
||||||
|
'QqBotMessageSubscriptionCreate',
|
||||||
|
'QqBotMessageSubscriptionUpdate',
|
||||||
|
'QqBotMessageSubscriptionDelete',
|
||||||
|
'QqBotMessageSubscriptionToggle',
|
||||||
|
'QqBotMessageTemplateList',
|
||||||
|
'QqBotMessageTemplateCreate',
|
||||||
|
'QqBotMessageTemplateUpdate',
|
||||||
|
'QqBotMessageTemplateDelete',
|
||||||
|
'QqBotMessageTemplateToggle',
|
||||||
|
'QqBotMessageTemplatePreview',
|
||||||
|
'QqBotAccountMessagePushList',
|
||||||
|
'QqBotAccountMessagePushCreate',
|
||||||
|
'QqBotAccountMessagePushUpdate',
|
||||||
|
'QqBotAccountMessagePushDelete',
|
||||||
|
'QqBotAccountMessagePushToggle',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the private menu whitelist's literal source array without exporting it.
|
||||||
|
* @returns Every string literal used to initialize `SUPPORTED_ADMIN_MENU_NAMES`.
|
||||||
|
*/
|
||||||
|
function getSupportedAdminMenuNameLiterals() {
|
||||||
|
const sourceFile = ts.createSourceFile(
|
||||||
|
'menu.ts',
|
||||||
|
readFileSync(resolve('apps/web-antdv-next/src/api/core/menu.ts'), 'utf8'),
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const declaration = sourceFile.statements
|
||||||
|
.filter((statement) => ts.isVariableStatement(statement))
|
||||||
|
.flatMap((statement) => statement.declarationList.declarations)
|
||||||
|
.find(
|
||||||
|
(candidate) =>
|
||||||
|
ts.isIdentifier(candidate.name) &&
|
||||||
|
candidate.name.text === 'SUPPORTED_ADMIN_MENU_NAMES',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(declaration?.initializer).toSatisfy(ts.isNewExpression);
|
||||||
|
|
||||||
|
const initializer = declaration?.initializer as ts.NewExpression;
|
||||||
|
expect(initializer.expression).toSatisfy(
|
||||||
|
(expression: ts.Expression) =>
|
||||||
|
ts.isIdentifier(expression) && expression.text === 'Set',
|
||||||
|
);
|
||||||
|
expect(initializer.arguments).toHaveLength(1);
|
||||||
|
expect(initializer.arguments?.[0]).toSatisfy(ts.isArrayLiteralExpression);
|
||||||
|
|
||||||
|
const elements = (initializer.arguments?.[0] as ts.ArrayLiteralExpression)
|
||||||
|
.elements;
|
||||||
|
expect(elements.every((element) => ts.isStringLiteral(element))).toBe(true);
|
||||||
|
|
||||||
|
return elements.map((element) => (element as ts.StringLiteral).text);
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('#/api/request', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
get: requestClientGet,
|
get: requestClientGet,
|
||||||
@ -393,26 +456,6 @@ describe('core menu api', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps every supported message-push menu name once while filtering unknown nodes', async () => {
|
it('keeps every supported message-push menu name once while filtering unknown nodes', async () => {
|
||||||
const messagePushNames = [
|
|
||||||
'QqBotMessageSubscription',
|
|
||||||
'QqBotMessageTemplate',
|
|
||||||
'QqBotMessageSubscriptionList',
|
|
||||||
'QqBotMessageSubscriptionCreate',
|
|
||||||
'QqBotMessageSubscriptionUpdate',
|
|
||||||
'QqBotMessageSubscriptionDelete',
|
|
||||||
'QqBotMessageSubscriptionToggle',
|
|
||||||
'QqBotMessageTemplateList',
|
|
||||||
'QqBotMessageTemplateCreate',
|
|
||||||
'QqBotMessageTemplateUpdate',
|
|
||||||
'QqBotMessageTemplateDelete',
|
|
||||||
'QqBotMessageTemplateToggle',
|
|
||||||
'QqBotMessageTemplatePreview',
|
|
||||||
'QqBotAccountMessagePushList',
|
|
||||||
'QqBotAccountMessagePushCreate',
|
|
||||||
'QqBotAccountMessagePushUpdate',
|
|
||||||
'QqBotAccountMessagePushDelete',
|
|
||||||
'QqBotAccountMessagePushToggle',
|
|
||||||
];
|
|
||||||
requestClientGet.mockResolvedValue([
|
requestClientGet.mockResolvedValue([
|
||||||
{
|
{
|
||||||
name: 'QqBot',
|
name: 'QqBot',
|
||||||
@ -422,7 +465,7 @@ describe('core menu api', () => {
|
|||||||
name: 'QqBotAccount',
|
name: 'QqBotAccount',
|
||||||
path: '/qqbot/account',
|
path: '/qqbot/account',
|
||||||
},
|
},
|
||||||
...messagePushNames.map((name) => ({
|
...messagePushMenuNames.map((name) => ({
|
||||||
authCode: `QqBot:${name}`,
|
authCode: `QqBot:${name}`,
|
||||||
name,
|
name,
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@ -440,8 +483,18 @@ describe('core menu api', () => {
|
|||||||
const qqbot = menus.find((menu) => menu.name === 'QqBot');
|
const qqbot = menus.find((menu) => menu.name === 'QqBot');
|
||||||
const retainedNames = qqbot?.children?.map((child) => child.name) ?? [];
|
const retainedNames = qqbot?.children?.map((child) => child.name) ?? [];
|
||||||
|
|
||||||
expect(retainedNames).toEqual(['QqBotAccount', ...messagePushNames]);
|
expect(retainedNames).toEqual(['QqBotAccount', ...messagePushMenuNames]);
|
||||||
expect(new Set(retainedNames).size).toBe(retainedNames.length);
|
expect(new Set(retainedNames).size).toBe(retainedNames.length);
|
||||||
expect(retainedNames).not.toContain('UnsupportedMessagePushNode');
|
expect(retainedNames).not.toContain('UnsupportedMessagePushNode');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('declares every locked message-push menu literal exactly once', () => {
|
||||||
|
const literals = getSupportedAdminMenuNameLiterals();
|
||||||
|
|
||||||
|
expect(new Set(literals).size).toBe(literals.length);
|
||||||
|
|
||||||
|
for (const name of messagePushMenuNames) {
|
||||||
|
expect(literals.filter((literal) => literal === name)).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
import type { QqbotMessagePushApi } from './message-push';
|
||||||
|
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks.
|
||||||
|
import ts from 'typescript';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
@ -26,6 +32,616 @@ import {
|
|||||||
updateMessageTemplate,
|
updateMessageTemplate,
|
||||||
} from './message-push';
|
} from './message-push';
|
||||||
|
|
||||||
|
type Equal<Actual, Expected> =
|
||||||
|
(<Value>() => Value extends Actual ? 1 : 2) extends <
|
||||||
|
Value,
|
||||||
|
>() => Value extends Expected ? 1 : 2
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
|
|
||||||
|
type Assert<Condition extends true> = Condition;
|
||||||
|
|
||||||
|
type ExpectedSystemMessageSourceVariableDefinition = {
|
||||||
|
description: string;
|
||||||
|
example: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'boolean' | 'number' | 'string';
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedSystemMessageSourceFieldDefinition = {
|
||||||
|
dependsOn?: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
optionCollection: 'ddnsRecords' | 'portForwards';
|
||||||
|
required: true;
|
||||||
|
type: 'select';
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedSystemMessageSourceDefinition = {
|
||||||
|
description: string;
|
||||||
|
displayName: string;
|
||||||
|
sourceKey: string;
|
||||||
|
subscriptionFields: ExpectedSystemMessageSourceFieldDefinition[];
|
||||||
|
variables: ExpectedSystemMessageSourceVariableDefinition[];
|
||||||
|
version: 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedStunMappingPortChangedOptionsResponse = {
|
||||||
|
ddnsRecords: Array<{
|
||||||
|
disabledReasonCode: null | string;
|
||||||
|
eligible: boolean;
|
||||||
|
fqdn: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
portForwardId: string;
|
||||||
|
}>;
|
||||||
|
portForwards: Array<{
|
||||||
|
disabledReasonCode: null | string;
|
||||||
|
eligible: boolean;
|
||||||
|
externalPort: number;
|
||||||
|
id: string;
|
||||||
|
internalPort: number;
|
||||||
|
name: string;
|
||||||
|
protocol: 'tcp' | 'udp';
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedMessageSubscriptionView = {
|
||||||
|
createTime: string;
|
||||||
|
enabled: boolean;
|
||||||
|
id: string;
|
||||||
|
invalidReasonCode: null | string;
|
||||||
|
name: string;
|
||||||
|
remark: null | string;
|
||||||
|
sourceConfig: { ddnsRecordId: string; portForwardId: string };
|
||||||
|
sourceKey: string;
|
||||||
|
sourceName: string;
|
||||||
|
sourceSummary: string;
|
||||||
|
updateTime: string;
|
||||||
|
valid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedMessageSubscriptionListQuery = {
|
||||||
|
enabled?: boolean;
|
||||||
|
name?: string;
|
||||||
|
pageNo?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sourceKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedMessageSubscriptionInput = {
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
sourceConfig: Record<string, unknown>;
|
||||||
|
sourceKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedMessageTemplateView = {
|
||||||
|
content: string;
|
||||||
|
createTime: string;
|
||||||
|
enabled: boolean;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
referenceCount: number;
|
||||||
|
remark: null | string;
|
||||||
|
sourceKey: string;
|
||||||
|
sourceName: string;
|
||||||
|
updateTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedMessageTemplateListQuery = ExpectedMessageSubscriptionListQuery;
|
||||||
|
|
||||||
|
type ExpectedMessageTemplateInput = {
|
||||||
|
content: string;
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
sourceKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedMessageTemplatePreview = {
|
||||||
|
renderedMessage: string;
|
||||||
|
variables: Record<string, boolean | number | string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedQqbotMessagePublishBindingInput = {
|
||||||
|
enabled: boolean;
|
||||||
|
subscriptionId: string;
|
||||||
|
targets: Array<{
|
||||||
|
targetId: string;
|
||||||
|
targetName?: string;
|
||||||
|
targetType: 'group' | 'private';
|
||||||
|
}>;
|
||||||
|
templateId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedQqbotMessagePushTargetOptionsResponse = {
|
||||||
|
available: boolean;
|
||||||
|
options: Array<{
|
||||||
|
label: string;
|
||||||
|
targetId: string;
|
||||||
|
targetType: 'group' | 'private';
|
||||||
|
}>;
|
||||||
|
reasonCode: null | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedQqbotMessagePublishBindingView = {
|
||||||
|
available: boolean;
|
||||||
|
createTime: string;
|
||||||
|
enabled: boolean;
|
||||||
|
id: string;
|
||||||
|
invalidReasonCode: null | string;
|
||||||
|
sourceKey: string;
|
||||||
|
sourceName: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
subscriptionName: string;
|
||||||
|
targets: Array<{
|
||||||
|
enabled: boolean;
|
||||||
|
id: string;
|
||||||
|
targetId: string;
|
||||||
|
targetName: null | string;
|
||||||
|
targetType: 'group' | 'private';
|
||||||
|
}>;
|
||||||
|
templateId: string;
|
||||||
|
templateName: string;
|
||||||
|
updateTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MessagePushTypeContracts = [
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.SystemMessageScalar,
|
||||||
|
boolean | null | number | string
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.SystemMessageSourceVariableDefinition,
|
||||||
|
ExpectedSystemMessageSourceVariableDefinition
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.SystemMessageSourceFieldDefinition,
|
||||||
|
ExpectedSystemMessageSourceFieldDefinition
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.SystemMessageSourceDefinition,
|
||||||
|
ExpectedSystemMessageSourceDefinition
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.StunMappingPortChangedSubscriptionConfig,
|
||||||
|
{ ddnsRecordId: string; portForwardId: string }
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.StunMappingPortChangedOptionsResponse,
|
||||||
|
ExpectedStunMappingPortChangedOptionsResponse
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.MessageSubscriptionView,
|
||||||
|
ExpectedMessageSubscriptionView
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.MessageSubscriptionListQuery,
|
||||||
|
ExpectedMessageSubscriptionListQuery
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.MessageSubscriptionInput,
|
||||||
|
ExpectedMessageSubscriptionInput
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<QqbotMessagePushApi.MessageTemplateView, ExpectedMessageTemplateView>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.MessageTemplateListQuery,
|
||||||
|
ExpectedMessageTemplateListQuery
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.MessageTemplateInput,
|
||||||
|
ExpectedMessageTemplateInput
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.MessageTemplatePreviewInput,
|
||||||
|
{ content: string; sourceKey: string }
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.MessageTemplatePreview,
|
||||||
|
ExpectedMessageTemplatePreview
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<QqbotMessagePushApi.QqbotMessagePushTargetType, 'group' | 'private'>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.QqbotMessagePublishTargetInput,
|
||||||
|
{ targetId: string; targetName?: string; targetType: 'group' | 'private' }
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.QqbotMessagePublishBindingInput,
|
||||||
|
ExpectedQqbotMessagePublishBindingInput
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.QqbotMessagePushTargetOption,
|
||||||
|
{ label: string; targetId: string; targetType: 'group' | 'private' }
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse,
|
||||||
|
ExpectedQqbotMessagePushTargetOptionsResponse
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.QqbotMessagePublishTargetView,
|
||||||
|
{
|
||||||
|
enabled: boolean;
|
||||||
|
id: string;
|
||||||
|
targetId: string;
|
||||||
|
targetName: null | string;
|
||||||
|
targetType: 'group' | 'private';
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.QqbotMessagePublishBindingView,
|
||||||
|
ExpectedQqbotMessagePublishBindingView
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
QqbotMessagePushApi.PageResult<ExpectedMessageSubscriptionView>,
|
||||||
|
{ items: ExpectedMessageSubscriptionView[]; total: number }
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<Equal<Parameters<typeof getMessagePushSources>, []>>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof getMessagePushSources>,
|
||||||
|
Promise<ExpectedSystemMessageSourceDefinition[]>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<Parameters<typeof getMessagePushSourceDetail>, [sourceKey: string]>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof getMessagePushSourceDetail>,
|
||||||
|
Promise<ExpectedSystemMessageSourceDefinition>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<Equal<Parameters<typeof getStunMappingPortChangedOptions>, []>>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof getStunMappingPortChangedOptions>,
|
||||||
|
Promise<ExpectedStunMappingPortChangedOptionsResponse>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof getMessageSubscriptionList>,
|
||||||
|
[params: ExpectedMessageSubscriptionListQuery]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof getMessageSubscriptionList>,
|
||||||
|
Promise<{ items: ExpectedMessageSubscriptionView[]; total: number }>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof createMessageSubscription>,
|
||||||
|
[data: ExpectedMessageSubscriptionInput]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof createMessageSubscription>,
|
||||||
|
Promise<ExpectedMessageSubscriptionView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof updateMessageSubscription>,
|
||||||
|
[id: string, data: ExpectedMessageSubscriptionInput]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof updateMessageSubscription>,
|
||||||
|
Promise<ExpectedMessageSubscriptionView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof setMessageSubscriptionEnabled>,
|
||||||
|
[id: string, enabled: boolean]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof setMessageSubscriptionEnabled>,
|
||||||
|
Promise<ExpectedMessageSubscriptionView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<Equal<Parameters<typeof deleteMessageSubscription>, [id: string]>>,
|
||||||
|
Assert<Equal<ReturnType<typeof deleteMessageSubscription>, Promise<boolean>>>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof getMessageTemplateList>,
|
||||||
|
[params: ExpectedMessageTemplateListQuery]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof getMessageTemplateList>,
|
||||||
|
Promise<{ items: ExpectedMessageTemplateView[]; total: number }>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof createMessageTemplate>,
|
||||||
|
[data: ExpectedMessageTemplateInput]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof createMessageTemplate>,
|
||||||
|
Promise<ExpectedMessageTemplateView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof updateMessageTemplate>,
|
||||||
|
[id: string, data: ExpectedMessageTemplateInput]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof updateMessageTemplate>,
|
||||||
|
Promise<ExpectedMessageTemplateView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof setMessageTemplateEnabled>,
|
||||||
|
[id: string, enabled: boolean]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof setMessageTemplateEnabled>,
|
||||||
|
Promise<ExpectedMessageTemplateView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<Equal<Parameters<typeof deleteMessageTemplate>, [id: string]>>,
|
||||||
|
Assert<Equal<ReturnType<typeof deleteMessageTemplate>, Promise<boolean>>>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof previewMessageTemplate>,
|
||||||
|
[data: { content: string; sourceKey: string }]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof previewMessageTemplate>,
|
||||||
|
Promise<ExpectedMessageTemplatePreview>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<Parameters<typeof getAccountMessagePushBindings>, [selfId: string]>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof getAccountMessagePushBindings>,
|
||||||
|
Promise<ExpectedQqbotMessagePublishBindingView[]>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof createAccountMessagePushBinding>,
|
||||||
|
[selfId: string, data: ExpectedQqbotMessagePublishBindingInput]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof createAccountMessagePushBinding>,
|
||||||
|
Promise<ExpectedQqbotMessagePublishBindingView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof updateAccountMessagePushBinding>,
|
||||||
|
[
|
||||||
|
selfId: string,
|
||||||
|
id: string,
|
||||||
|
data: ExpectedQqbotMessagePublishBindingInput,
|
||||||
|
]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof updateAccountMessagePushBinding>,
|
||||||
|
Promise<ExpectedQqbotMessagePublishBindingView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof setAccountMessagePushBindingEnabled>,
|
||||||
|
[selfId: string, id: string, enabled: boolean]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof setAccountMessagePushBindingEnabled>,
|
||||||
|
Promise<ExpectedQqbotMessagePublishBindingView>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
Parameters<typeof deleteAccountMessagePushBinding>,
|
||||||
|
[selfId: string, id: string]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<ReturnType<typeof deleteAccountMessagePushBinding>, Promise<boolean>>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<Parameters<typeof getAccountMessagePushTargets>, [selfId: string]>
|
||||||
|
>,
|
||||||
|
Assert<
|
||||||
|
Equal<
|
||||||
|
ReturnType<typeof getAccountMessagePushTargets>,
|
||||||
|
Promise<ExpectedQqbotMessagePushTargetOptionsResponse>
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
];
|
||||||
|
|
||||||
|
const messagePushCallerNames = [
|
||||||
|
'createAccountMessagePushBinding',
|
||||||
|
'createMessageSubscription',
|
||||||
|
'createMessageTemplate',
|
||||||
|
'deleteAccountMessagePushBinding',
|
||||||
|
'deleteMessageSubscription',
|
||||||
|
'deleteMessageTemplate',
|
||||||
|
'getAccountMessagePushBindings',
|
||||||
|
'getAccountMessagePushTargets',
|
||||||
|
'getMessagePushSourceDetail',
|
||||||
|
'getMessagePushSources',
|
||||||
|
'getMessageSubscriptionList',
|
||||||
|
'getMessageTemplateList',
|
||||||
|
'getStunMappingPortChangedOptions',
|
||||||
|
'previewMessageTemplate',
|
||||||
|
'setAccountMessagePushBindingEnabled',
|
||||||
|
'setMessageSubscriptionEnabled',
|
||||||
|
'setMessageTemplateEnabled',
|
||||||
|
'updateAccountMessagePushBinding',
|
||||||
|
'updateMessageSubscription',
|
||||||
|
'updateMessageTemplate',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-checks this focused contract spec with a virtual request client boundary.
|
||||||
|
* @returns Deterministic TypeScript diagnostics for only this spec and `message-push.ts`.
|
||||||
|
*/
|
||||||
|
function getMessagePushContractDiagnostics() {
|
||||||
|
const appRoot = resolve('apps/web-antdv-next');
|
||||||
|
const configPath = resolve(appRoot, 'tsconfig.json');
|
||||||
|
const testFilePath = resolve(appRoot, 'src/api/qqbot/message-push.spec.ts');
|
||||||
|
const virtualRequestModulePath =
|
||||||
|
'/__qqbot-message-push-contract__/request.ts';
|
||||||
|
const virtualRequestModuleSource = `
|
||||||
|
export declare const requestClient: {
|
||||||
|
delete<T>(url: string, config?: unknown): Promise<T>;
|
||||||
|
get<T>(url: string, config?: unknown): Promise<T>;
|
||||||
|
post<T>(url: string, data?: unknown, config?: unknown): Promise<T>;
|
||||||
|
put<T>(url: string, data?: unknown, config?: unknown): Promise<T>;
|
||||||
|
};`;
|
||||||
|
const config = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||||
|
const parsed = ts.parseJsonConfigFileContent(
|
||||||
|
config.config,
|
||||||
|
ts.sys,
|
||||||
|
appRoot,
|
||||||
|
{ incremental: false, noEmit: true },
|
||||||
|
configPath,
|
||||||
|
);
|
||||||
|
const baseCompilerHost = ts.createCompilerHost(parsed.options, true);
|
||||||
|
const compilerHost: ts.CompilerHost = {
|
||||||
|
...baseCompilerHost,
|
||||||
|
fileExists(fileName) {
|
||||||
|
return (
|
||||||
|
fileName === virtualRequestModulePath ||
|
||||||
|
baseCompilerHost.fileExists(fileName)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getSourceFile(
|
||||||
|
fileName,
|
||||||
|
languageVersion,
|
||||||
|
onError,
|
||||||
|
shouldCreateNewSourceFile,
|
||||||
|
) {
|
||||||
|
if (fileName === virtualRequestModulePath) {
|
||||||
|
return ts.createSourceFile(
|
||||||
|
fileName,
|
||||||
|
virtualRequestModuleSource,
|
||||||
|
languageVersion,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseCompilerHost.getSourceFile(
|
||||||
|
fileName,
|
||||||
|
languageVersion,
|
||||||
|
onError,
|
||||||
|
shouldCreateNewSourceFile,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readFile(fileName) {
|
||||||
|
return fileName === virtualRequestModulePath
|
||||||
|
? virtualRequestModuleSource
|
||||||
|
: baseCompilerHost.readFile(fileName);
|
||||||
|
},
|
||||||
|
resolveModuleNames(moduleNames, containingFile) {
|
||||||
|
return moduleNames.map((moduleName) => {
|
||||||
|
if (moduleName === '#/api/request') {
|
||||||
|
return {
|
||||||
|
extension: ts.Extension.Ts,
|
||||||
|
isExternalLibraryImport: false,
|
||||||
|
resolvedFileName: virtualRequestModulePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ts.resolveModuleName(
|
||||||
|
moduleName,
|
||||||
|
containingFile,
|
||||||
|
parsed.options,
|
||||||
|
baseCompilerHost,
|
||||||
|
).resolvedModule;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const program = ts.createProgram({
|
||||||
|
host: compilerHost,
|
||||||
|
options: parsed.options,
|
||||||
|
rootNames: [testFilePath],
|
||||||
|
});
|
||||||
|
|
||||||
|
return ts
|
||||||
|
.getPreEmitDiagnostics(program)
|
||||||
|
.map((diagnostic) =>
|
||||||
|
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('#/api/request', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@ -214,11 +830,16 @@ describe('qqbot message push api', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose internal publish, event, delivery, or worker callers', () => {
|
it('exports exactly the twenty approved callers', () => {
|
||||||
expect(Object.keys(messagePushApi)).not.toEqual(
|
expect(Object.keys(messagePushApi).toSorted()).toEqual(
|
||||||
expect.arrayContaining([
|
messagePushCallerNames,
|
||||||
expect.stringMatching(/delivery|event|publish|worker/i),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('type-checks the complete public namespace and caller contract', () => {
|
||||||
|
const typeContracts: MessagePushTypeContracts = [] as never;
|
||||||
|
|
||||||
|
void typeContracts;
|
||||||
|
expect(getMessagePushContractDiagnostics()).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,33 +1,212 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks.
|
||||||
|
import ts from 'typescript';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('qqbot routes', () => {
|
type ExpectedMessagePushRoute = {
|
||||||
it('adds two flat message-push route children without loading future pages', () => {
|
componentPath: string;
|
||||||
const source = readFileSync(
|
name: string;
|
||||||
'apps/web-antdv-next/src/router/routes/modules/qqbot.ts',
|
path: string;
|
||||||
'utf8',
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const messagePushRoutes: ExpectedMessagePushRoute[] = [
|
||||||
|
{
|
||||||
|
componentPath: '#/views/qqbot/message-subscription/list',
|
||||||
|
name: 'QqBotMessageSubscription',
|
||||||
|
path: '/qqbot/message-subscription',
|
||||||
|
title: '消息订阅',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
componentPath: '#/views/qqbot/message-template/list',
|
||||||
|
name: 'QqBotMessageTemplate',
|
||||||
|
path: '/qqbot/message-template',
|
||||||
|
title: '消息模板',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the text key of a non-computed object-literal property.
|
||||||
|
* @param property - Candidate route object property from the parsed source.
|
||||||
|
* @returns The literal property key, or `undefined` when it cannot be read statically.
|
||||||
|
*/
|
||||||
|
function getPropertyName(property: ts.ObjectLiteralElementLike) {
|
||||||
|
if (!ts.isPropertyAssignment(property) || property.name === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)
|
||||||
|
? property.name.text
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds one directly declared property on a route object.
|
||||||
|
* @param object - Route object literal to inspect.
|
||||||
|
* @param name - Required literal property key.
|
||||||
|
* @returns The matching property assignment, if present.
|
||||||
|
*/
|
||||||
|
function getProperty(object: ts.ObjectLiteralExpression, name: string) {
|
||||||
|
return object.properties.find(
|
||||||
|
(property): property is ts.PropertyAssignment =>
|
||||||
|
getPropertyName(property) === name && ts.isPropertyAssignment(property),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a required string-literal property from a route object.
|
||||||
|
* @param object - Route object literal to inspect.
|
||||||
|
* @param name - Required literal property key.
|
||||||
|
* @returns The declared string value, or `undefined` when it is not a string literal.
|
||||||
|
*/
|
||||||
|
function getStringProperty(object: ts.ObjectLiteralExpression, name: string) {
|
||||||
|
const property = getProperty(object, name);
|
||||||
|
|
||||||
|
return property && ts.isStringLiteral(property.initializer)
|
||||||
|
? property.initializer.text
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the literal target from a route's lazy dynamic import without invoking it.
|
||||||
|
* @param object - Route object literal to inspect.
|
||||||
|
* @returns The module path passed to `import()`.
|
||||||
|
*/
|
||||||
|
function getLazyComponentPath(object: ts.ObjectLiteralExpression) {
|
||||||
|
const component = getProperty(object, 'component');
|
||||||
|
|
||||||
|
expect(component, 'route must declare a lazy component').toBeDefined();
|
||||||
|
expect(component?.initializer).toSatisfy(ts.isArrowFunction);
|
||||||
|
|
||||||
|
const body = (component?.initializer as ts.ArrowFunction).body;
|
||||||
|
expect(body).toSatisfy(ts.isCallExpression);
|
||||||
|
|
||||||
|
const importCall = body as ts.CallExpression;
|
||||||
|
expect(importCall.expression.kind).toBe(ts.SyntaxKind.ImportKeyword);
|
||||||
|
expect(importCall.arguments).toHaveLength(1);
|
||||||
|
expect(importCall.arguments[0]).toSatisfy(ts.isStringLiteral);
|
||||||
|
|
||||||
|
return (importCall.arguments[0] as ts.StringLiteral).text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locates the direct `QqBot.children` array in the route initializer.
|
||||||
|
* @param sourceFile - Parsed QQBot route module source.
|
||||||
|
* @returns The direct child route objects of the `QqBot` parent.
|
||||||
|
*/
|
||||||
|
function getQqBotChildren(sourceFile: ts.SourceFile) {
|
||||||
|
const routesDeclaration = sourceFile.statements.find(
|
||||||
|
(statement): statement is ts.VariableStatement => {
|
||||||
|
if (!ts.isVariableStatement(statement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return statement.declarationList.declarations.some(
|
||||||
|
(declaration) =>
|
||||||
|
ts.isIdentifier(declaration.name) &&
|
||||||
|
declaration.name.text === 'routes',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const routesInitializer =
|
||||||
|
routesDeclaration?.declarationList.declarations.find(
|
||||||
|
(declaration) =>
|
||||||
|
ts.isIdentifier(declaration.name) && declaration.name.text === 'routes',
|
||||||
|
)?.initializer;
|
||||||
|
|
||||||
|
expect(routesInitializer).toSatisfy(ts.isArrayLiteralExpression);
|
||||||
|
|
||||||
|
const qqbotRoute = (
|
||||||
|
routesInitializer as ts.ArrayLiteralExpression
|
||||||
|
).elements.find(
|
||||||
|
(element): element is ts.ObjectLiteralExpression =>
|
||||||
|
ts.isObjectLiteralExpression(element) &&
|
||||||
|
getStringProperty(element, 'name') === 'QqBot',
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [name, path, componentPath, title] of [
|
expect(qqbotRoute, 'routes must contain a direct QqBot parent').toBeDefined();
|
||||||
[
|
|
||||||
'QqBotMessageSubscription',
|
const children = getProperty(
|
||||||
'/qqbot/message-subscription',
|
qqbotRoute as ts.ObjectLiteralExpression,
|
||||||
'#/views/qqbot/message-subscription/list',
|
'children',
|
||||||
'消息订阅',
|
);
|
||||||
],
|
expect(children?.initializer).toSatisfy(ts.isArrayLiteralExpression);
|
||||||
[
|
|
||||||
'QqBotMessageTemplate',
|
return (children?.initializer as ts.ArrayLiteralExpression).elements.filter(
|
||||||
'/qqbot/message-template',
|
(element) => ts.isObjectLiteralExpression(element),
|
||||||
'#/views/qqbot/message-template/list',
|
);
|
||||||
'消息模板',
|
}
|
||||||
],
|
|
||||||
] as const) {
|
/**
|
||||||
expect(source).toMatch(
|
* Collects every route object that declares one of the locked message-push names.
|
||||||
new RegExp(
|
* @param sourceFile - Parsed QQBot route module source.
|
||||||
String.raw`\{\s*component: \(\) => import\('${componentPath}'\),\s*meta: \{\s*icon: 'lucide:[^']+',\s*title: '${title}',\s*\},\s*name: '${name}',\s*path: '${path}',\s*\}`,
|
* @returns All matching objects, including accidental nested or duplicate routes.
|
||||||
's',
|
*/
|
||||||
|
function getAllMessagePushRoutes(sourceFile: ts.SourceFile) {
|
||||||
|
const routeNames = new Set(messagePushRoutes.map((route) => route.name));
|
||||||
|
const matches: ts.ObjectLiteralExpression[] = [];
|
||||||
|
|
||||||
|
/** Visits route objects so nested copies cannot evade the direct-child assertion. */
|
||||||
|
function visit(node: ts.Node): void {
|
||||||
|
if (
|
||||||
|
ts.isObjectLiteralExpression(node) &&
|
||||||
|
routeNames.has(getStringProperty(node, 'name'))
|
||||||
|
) {
|
||||||
|
matches.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(sourceFile);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('qqbot routes', () => {
|
||||||
|
it('adds exactly two flat message-push route children without loading future pages', () => {
|
||||||
|
const sourceFile = ts.createSourceFile(
|
||||||
|
'qqbot.ts',
|
||||||
|
readFileSync(
|
||||||
|
resolve('apps/web-antdv-next/src/router/routes/modules/qqbot.ts'),
|
||||||
|
'utf8',
|
||||||
),
|
),
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const directChildren = getQqBotChildren(sourceFile);
|
||||||
|
const allMessagePushRouteNodes = getAllMessagePushRoutes(sourceFile);
|
||||||
|
|
||||||
|
expect(allMessagePushRouteNodes).toHaveLength(messagePushRoutes.length);
|
||||||
|
|
||||||
|
for (const expectedRoute of messagePushRoutes) {
|
||||||
|
const matchingDirectChildren = directChildren.filter(
|
||||||
|
(route) => getStringProperty(route, 'name') === expectedRoute.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(matchingDirectChildren).toHaveLength(1);
|
||||||
|
|
||||||
|
const route = matchingDirectChildren[0];
|
||||||
|
if (!route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getStringProperty(route, 'path')).toBe(expectedRoute.path);
|
||||||
|
expect(getLazyComponentPath(route)).toBe(expectedRoute.componentPath);
|
||||||
|
expect(getProperty(route, 'children')).toBeUndefined();
|
||||||
|
|
||||||
|
const meta = getProperty(route, 'meta');
|
||||||
|
expect(meta?.initializer).toSatisfy(ts.isObjectLiteralExpression);
|
||||||
|
if (!meta || !ts.isObjectLiteralExpression(meta.initializer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getStringProperty(meta.initializer, 'title')).toBe(
|
||||||
|
expectedRoute.title,
|
||||||
|
);
|
||||||
|
expect(getStringProperty(meta.initializer, 'icon')).toMatch(
|
||||||
|
/^lucide:\S+$/,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user