Files
back_end_oudelaa/src/adapter-contracts.spec.ts
T

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);
}
}
},
);
});