373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import * as ts from 'typescript';
|
|
|
|
const sourceRoot = resolve(__dirname);
|
|
const validObjectId = '507f1f77bcf86cd799439011';
|
|
|
|
type Constructor = new (...args: any[]) => any;
|
|
type Operation = { dependency: number; method: string; args: unknown[] };
|
|
|
|
function collectAdapterFiles(directory: string): string[] {
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return collectAdapterFiles(path);
|
|
}
|
|
return entry.name.endsWith('.controller.ts') || entry.name.endsWith('.repository.ts') ? [path] : [];
|
|
});
|
|
}
|
|
|
|
function publicMethodParameters(file: string, className: string): Map<string, string[]> {
|
|
const source = ts.createSourceFile(
|
|
file,
|
|
readFileSync(file, 'utf8'),
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
ts.ScriptKind.TS,
|
|
);
|
|
const methods = new Map<string, string[]>();
|
|
|
|
source.forEachChild((node) => {
|
|
if (!ts.isClassDeclaration(node) || node.name?.text !== className) return;
|
|
for (const member of node.members) {
|
|
if (!ts.isMethodDeclaration(member) || !member.name) continue;
|
|
const isPrivate = member.modifiers?.some(
|
|
(modifier) =>
|
|
modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword,
|
|
);
|
|
if (isPrivate) continue;
|
|
methods.set(
|
|
member.name.getText(source).replace(/^['"]|['"]$/g, ''),
|
|
member.parameters.map((parameter) => parameter.name.getText(source)),
|
|
);
|
|
}
|
|
});
|
|
|
|
return methods;
|
|
}
|
|
|
|
function createTransportValue(): any {
|
|
const target = function transportValue() {
|
|
return value;
|
|
};
|
|
Object.assign(target, {
|
|
status: 'ready',
|
|
success: true,
|
|
checks: {},
|
|
items: [],
|
|
data: [],
|
|
user: {
|
|
sub: validObjectId,
|
|
userId: validObjectId,
|
|
id: validObjectId,
|
|
email: 'member@example.com',
|
|
permissions: [],
|
|
},
|
|
});
|
|
const value: any = new Proxy(target, {
|
|
apply: () => value,
|
|
get: (object, property) => {
|
|
if (property === 'then') return undefined;
|
|
if (property === Symbol.iterator) return function* emptyIterator() {};
|
|
if (property === Symbol.toPrimitive) return () => validObjectId;
|
|
if (property === 'toString' || property === 'valueOf') return () => validObjectId;
|
|
if (property === 'map' || property === 'filter' || property === 'slice') return () => [];
|
|
if (Reflect.has(object, property)) return Reflect.get(object, property);
|
|
return value;
|
|
},
|
|
});
|
|
return value;
|
|
}
|
|
|
|
function representativePayload() {
|
|
return {
|
|
id: validObjectId,
|
|
_id: validObjectId,
|
|
sub: validObjectId,
|
|
userId: validObjectId,
|
|
authorId: validObjectId,
|
|
ownerId: validObjectId,
|
|
ownerAdminId: validObjectId,
|
|
postId: validObjectId,
|
|
commentId: validObjectId,
|
|
targetId: validObjectId,
|
|
conversationId: validObjectId,
|
|
participantId: validObjectId,
|
|
recipientId: validObjectId,
|
|
parentCommentId: validObjectId,
|
|
email: 'member@example.com',
|
|
username: 'member',
|
|
name: 'Member',
|
|
content: 'contract probe',
|
|
title: 'Contract probe',
|
|
reason: 'contract probe',
|
|
type: 'post',
|
|
targetType: 'post',
|
|
resourceType: 'post',
|
|
status: 'active',
|
|
action: 'activate',
|
|
isActive: true,
|
|
isDisabled: false,
|
|
targetIds: [validObjectId],
|
|
participantIds: [validObjectId],
|
|
userIds: [validObjectId],
|
|
deviceId: 'device-contract',
|
|
fcmToken: 'token-contract',
|
|
imageUrls: [],
|
|
tags: [],
|
|
page: 1,
|
|
limit: 2,
|
|
};
|
|
}
|
|
|
|
function argumentFor(parameterName: string): unknown {
|
|
const name = parameterName.toLowerCase();
|
|
const upload = {
|
|
originalname: 'image.jpg',
|
|
mimetype: 'image/jpeg',
|
|
size: 3,
|
|
buffer: Buffer.from([0xff, 0xd8, 0xff]),
|
|
};
|
|
if (name === 'req' || name === 'request' || name.endsWith('request')) {
|
|
return {
|
|
user: representativePayload(),
|
|
headers: { 'x-request-id': 'contract-request' },
|
|
method: 'GET',
|
|
originalUrl: '/contract',
|
|
};
|
|
}
|
|
if (name === 'res' || name.includes('response')) {
|
|
const response: any = new Proxy(
|
|
{},
|
|
{
|
|
get: (target, property) => {
|
|
if (!Reflect.has(target, property)) {
|
|
Reflect.set(target, property, jest.fn(() => response));
|
|
}
|
|
return Reflect.get(target, property);
|
|
},
|
|
},
|
|
);
|
|
return response;
|
|
}
|
|
if (name === 'user' || name.includes('currentuser')) return representativePayload();
|
|
if (name.includes('files')) {
|
|
return Object.assign([upload], {
|
|
imageFiles: [upload],
|
|
shopImageFiles: [upload],
|
|
avatarFile: [upload],
|
|
audioFile: [upload],
|
|
videoFile: [upload],
|
|
});
|
|
}
|
|
if (name.includes('file')) return upload;
|
|
if (/(^|_)(ids|idlist)$/.test(name) || name.endsWith('ids')) return [validObjectId];
|
|
if (name.endsWith('id') || name === 'jti') return validObjectId;
|
|
if (name.includes('limit')) return 2;
|
|
if (name.includes('skip') || name.includes('offset')) return 0;
|
|
if (name.includes('page')) return 1;
|
|
if (name.includes('direction')) return -1;
|
|
if (name.includes('session')) return undefined;
|
|
if (name.includes('email')) return 'member@example.com';
|
|
if (name.endsWith('names') || name === 'usernames') return ['member'];
|
|
if (name.includes('text') || name.includes('content') || name.includes('title') || name.includes('reason')) {
|
|
return 'contract probe';
|
|
}
|
|
if (name === 'q' || name.includes('search')) return 'contract';
|
|
if (name.includes('sort')) return { createdAt: -1 };
|
|
if (name.includes('filter')) return {};
|
|
return representativePayload();
|
|
}
|
|
|
|
function createControllerDependency(index: number, operations: Operation[]) {
|
|
const methods = new Map<PropertyKey, jest.Mock>();
|
|
return new Proxy(
|
|
{},
|
|
{
|
|
get: (_target, property) => {
|
|
if (property === 'then') return undefined;
|
|
if (!methods.has(property)) {
|
|
methods.set(
|
|
property,
|
|
jest.fn((...args: unknown[]) => {
|
|
operations.push({ dependency: index, method: String(property), args });
|
|
return createTransportValue();
|
|
}),
|
|
);
|
|
}
|
|
return methods.get(property);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function createRepositoryModel(index: number, operations: Operation[]) {
|
|
const document: any = {
|
|
id: validObjectId,
|
|
_id: validObjectId,
|
|
participantIds: [validObjectId],
|
|
read: false,
|
|
modifiedCount: 1,
|
|
deletedCount: 1,
|
|
populate: jest.fn(async () => document),
|
|
save: jest.fn(async (...args: unknown[]): Promise<unknown> => {
|
|
operations.push({ dependency: index, method: 'document.save', args });
|
|
return document;
|
|
}),
|
|
};
|
|
const createQuery = () => {
|
|
const queryResult: any[] = [];
|
|
Object.assign(queryResult, document);
|
|
const query: any = new Proxy(
|
|
{},
|
|
{
|
|
get: (_target, property) => {
|
|
if (property === 'then') return undefined;
|
|
if (property === 'exec') {
|
|
return jest.fn(async () => {
|
|
operations.push({ dependency: index, method: 'query.exec', args: [] });
|
|
return queryResult;
|
|
});
|
|
}
|
|
return jest.fn((...args: unknown[]) => {
|
|
operations.push({ dependency: index, method: `query.${String(property)}`, args });
|
|
return query;
|
|
});
|
|
},
|
|
},
|
|
);
|
|
return query;
|
|
};
|
|
const methods = new Map<PropertyKey, jest.Mock>();
|
|
const modelTarget = function modelConstructor() {
|
|
return document;
|
|
};
|
|
|
|
return new Proxy(modelTarget, {
|
|
construct: (_target, args) => {
|
|
operations.push({ dependency: index, method: 'new', args });
|
|
return document;
|
|
},
|
|
get: (_target, property) => {
|
|
if (property === 'collection') return { name: `collection_${index}` };
|
|
if (!methods.has(property)) {
|
|
methods.set(
|
|
property,
|
|
jest.fn((...args: unknown[]) => {
|
|
operations.push({ dependency: index, method: String(property), args });
|
|
return property === 'create'
|
|
? Promise.resolve(Array.isArray(args[0]) ? [document] : document)
|
|
: createQuery();
|
|
}),
|
|
);
|
|
}
|
|
return methods.get(property);
|
|
},
|
|
});
|
|
}
|
|
|
|
describe('controller and repository contracts', () => {
|
|
const files = collectAdapterFiles(sourceRoot);
|
|
|
|
it.each(files.filter((file) => file.endsWith('.controller.ts')))(
|
|
'%s delegates every public handler or enforces its documented local rejection',
|
|
async (file) => {
|
|
const moduleExports = require(file) as Record<string, Constructor>;
|
|
const controllers = Object.values(moduleExports).filter(
|
|
(exported): exported is Constructor =>
|
|
typeof exported === 'function' && exported.name.endsWith('Controller'),
|
|
);
|
|
expect(controllers).not.toHaveLength(0);
|
|
|
|
for (const Controller of controllers) {
|
|
const operations: Operation[] = [];
|
|
const dependencies = Array.from({ length: Controller.length }, (_, index) =>
|
|
createControllerDependency(index, operations),
|
|
);
|
|
const instance = Reflect.construct(Controller, dependencies);
|
|
const methods = publicMethodParameters(file, Controller.name);
|
|
expect(methods.size).toBeGreaterThan(0);
|
|
|
|
for (const [method, parameters] of methods) {
|
|
const key = `${Controller.name}.${method}`;
|
|
const args = parameters.map(argumentFor);
|
|
const before = operations.length;
|
|
|
|
if (key === 'ChatController.messagesMissingConversationId') {
|
|
await expect(Promise.resolve().then(() => instance[method](...args))).rejects.toThrow(
|
|
'conversationId is required',
|
|
);
|
|
expect(operations).toHaveLength(before);
|
|
continue;
|
|
}
|
|
if (key === 'AuthController.googleAuth') {
|
|
await expect(Promise.resolve(instance[method](...args))).resolves.toBeUndefined();
|
|
expect(operations).toHaveLength(before);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await instance[method](...args);
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`${key} failed its delegation contract: ${detail}`);
|
|
}
|
|
const delegated = operations.slice(before);
|
|
if (!delegated.length) {
|
|
throw new Error(`${key} did not delegate to an injected dependency`);
|
|
}
|
|
expect(delegated[0]).toEqual(
|
|
expect.objectContaining({ dependency: expect.any(Number), method: expect.any(String) }),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(files.filter((file) => file.endsWith('.repository.ts')))(
|
|
'%s routes every public operation through its persistence model',
|
|
async (file) => {
|
|
const moduleExports = require(file) as Record<string, Constructor>;
|
|
const repositories = Object.values(moduleExports).filter(
|
|
(exported): exported is Constructor =>
|
|
typeof exported === 'function' && exported.name.endsWith('Repository'),
|
|
);
|
|
expect(repositories).not.toHaveLength(0);
|
|
|
|
for (const Repository of repositories) {
|
|
const operations: Operation[] = [];
|
|
const dependencies = Array.from({ length: Repository.length }, (_, index) =>
|
|
createRepositoryModel(index, operations),
|
|
);
|
|
const instance = Reflect.construct(Repository, dependencies);
|
|
const methods = publicMethodParameters(file, Repository.name);
|
|
expect(methods.size).toBeGreaterThan(0);
|
|
|
|
for (const [method, parameters] of methods) {
|
|
const before = operations.length;
|
|
const args = parameters.map(argumentFor);
|
|
if (`${Repository.name}.${method}` === 'FollowsRepository.isDuplicateKeyError') {
|
|
expect(instance[method]({ code: 11000 })).toBe(true);
|
|
expect(instance[method]({ code: 1 })).toBe(false);
|
|
expect(operations).toHaveLength(before);
|
|
continue;
|
|
}
|
|
try {
|
|
await instance[method](...args);
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`${Repository.name}.${method} failed its persistence contract: ${detail}`);
|
|
}
|
|
|
|
const persistenceCalls = operations.slice(before);
|
|
if (!persistenceCalls.length) {
|
|
throw new Error(`${Repository.name}.${method} did not reach an injected persistence model`);
|
|
}
|
|
expect(persistenceCalls.some((operation) => operation.method !== 'toString')).toBe(true);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|