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';
|
||||
|
||||
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', () => ({
|
||||
requestClient: {
|
||||
get: requestClientGet,
|
||||
@ -393,26 +456,6 @@ describe('core menu api', () => {
|
||||
});
|
||||
|
||||
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([
|
||||
{
|
||||
name: 'QqBot',
|
||||
@ -422,7 +465,7 @@ describe('core menu api', () => {
|
||||
name: 'QqBotAccount',
|
||||
path: '/qqbot/account',
|
||||
},
|
||||
...messagePushNames.map((name) => ({
|
||||
...messagePushMenuNames.map((name) => ({
|
||||
authCode: `QqBot:${name}`,
|
||||
name,
|
||||
type: 'button',
|
||||
@ -440,8 +483,18 @@ describe('core menu api', () => {
|
||||
const qqbot = menus.find((menu) => menu.name === 'QqBot');
|
||||
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(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 { requestClient } from '#/api/request';
|
||||
@ -26,6 +32,616 @@ import {
|
||||
updateMessageTemplate,
|
||||
} 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', () => ({
|
||||
requestClient: {
|
||||
delete: vi.fn(),
|
||||
@ -214,11 +830,16 @@ describe('qqbot message push api', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose internal publish, event, delivery, or worker callers', () => {
|
||||
expect(Object.keys(messagePushApi)).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/delivery|event|publish|worker/i),
|
||||
]),
|
||||
it('exports exactly the twenty approved callers', () => {
|
||||
expect(Object.keys(messagePushApi).toSorted()).toEqual(
|
||||
messagePushCallerNames,
|
||||
);
|
||||
});
|
||||
|
||||
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 { 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';
|
||||
|
||||
describe('qqbot routes', () => {
|
||||
it('adds two flat message-push route children without loading future pages', () => {
|
||||
const source = readFileSync(
|
||||
'apps/web-antdv-next/src/router/routes/modules/qqbot.ts',
|
||||
'utf8',
|
||||
);
|
||||
type ExpectedMessagePushRoute = {
|
||||
componentPath: string;
|
||||
name: string;
|
||||
path: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
for (const [name, path, componentPath, title] of [
|
||||
[
|
||||
'QqBotMessageSubscription',
|
||||
'/qqbot/message-subscription',
|
||||
'#/views/qqbot/message-subscription/list',
|
||||
'消息订阅',
|
||||
],
|
||||
[
|
||||
'QqBotMessageTemplate',
|
||||
'/qqbot/message-template',
|
||||
'#/views/qqbot/message-template/list',
|
||||
'消息模板',
|
||||
],
|
||||
] as const) {
|
||||
expect(source).toMatch(
|
||||
new RegExp(
|
||||
String.raw`\{\s*component: \(\) => import\('${componentPath}'\),\s*meta: \{\s*icon: 'lucide:[^']+',\s*title: '${title}',\s*\},\s*name: '${name}',\s*path: '${path}',\s*\}`,
|
||||
's',
|
||||
),
|
||||
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',
|
||||
);
|
||||
|
||||
expect(qqbotRoute, 'routes must contain a direct QqBot parent').toBeDefined();
|
||||
|
||||
const children = getProperty(
|
||||
qqbotRoute as ts.ObjectLiteralExpression,
|
||||
'children',
|
||||
);
|
||||
expect(children?.initializer).toSatisfy(ts.isArrayLiteralExpression);
|
||||
|
||||
return (children?.initializer as ts.ArrayLiteralExpression).elements.filter(
|
||||
(element) => ts.isObjectLiteralExpression(element),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every route object that declares one of the locked message-push names.
|
||||
* @param sourceFile - Parsed QQBot route module source.
|
||||
* @returns All matching objects, including accidental nested or duplicate routes.
|
||||
*/
|
||||
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