@gnapi/cotester 1.2.11 → 1.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts +17 -0
- package/dist/analyzer.js +121 -14
- package/dist/analyzer.js.map +1 -1
- package/dist/generator.js +310 -84
- package/dist/generator.js.map +1 -1
- package/dist/interfaceShapeResolver.js +9 -0
- package/dist/interfaceShapeResolver.js.map +1 -1
- package/dist/mockDataEngine.js +5 -3
- package/dist/mockDataEngine.js.map +1 -1
- package/dist/mockGenerator.js +39 -11
- package/dist/mockGenerator.js.map +1 -1
- package/dist/ormMockGenerator.js +22 -20
- package/dist/ormMockGenerator.js.map +1 -1
- package/dist/scenarioEngine.d.ts +20 -0
- package/dist/scenarioEngine.js +54 -40
- package/dist/scenarioEngine.js.map +1 -1
- package/dist/sharedMockRegistry.js +1 -1
- package/dist/sharedMockRegistry.js.map +1 -1
- package/package.json +1 -1
package/dist/generator.js
CHANGED
|
@@ -392,6 +392,24 @@ function buildTestSource(scenarios, srcFilePath, testFilePath, mockResult, proje
|
|
|
392
392
|
lines.push(adapter.mockModule(depImportPath, dep.importedNames));
|
|
393
393
|
lines.push("");
|
|
394
394
|
}
|
|
395
|
+
// ── Auto-mock @nestjs/swagger to prevent decorator crashes ─────
|
|
396
|
+
// NestJS Swagger decorators call into metadata reflection at import time,
|
|
397
|
+
// which fails in a pure-Jest context. Mock the entire module with no-op
|
|
398
|
+
// decorator factories.
|
|
399
|
+
if (srcFilePath.includes(".controller.") ||
|
|
400
|
+
srcFilePath.includes(".dto.") ||
|
|
401
|
+
srcFilePath.includes("swagger") ||
|
|
402
|
+
allDeps.some(d => d.modulePath.includes("swagger") || d.modulePath.includes(".dto") || d.modulePath.includes(".controller"))) {
|
|
403
|
+
lines.push(`jest.mock('@nestjs/swagger', () => {`);
|
|
404
|
+
lines.push(` const noop = () => (_target: any, _key?: any, _desc?: any) => _desc ?? _target;`);
|
|
405
|
+
lines.push(` const noopClass = () => (target: any) => target;`);
|
|
406
|
+
lines.push(` return new Proxy({}, { get: (_t, prop) => {`);
|
|
407
|
+
lines.push(` if (prop === '__esModule') return true;`);
|
|
408
|
+
lines.push(` return (...args: any[]) => args[0]?.constructor === Function || typeof args[0] === 'function' ? args[0] : noop();`);
|
|
409
|
+
lines.push(` }});`);
|
|
410
|
+
lines.push(`});`);
|
|
411
|
+
lines.push(``);
|
|
412
|
+
}
|
|
395
413
|
// ── Import the source module ────────────────────────────────────
|
|
396
414
|
const importPath = computeRelativeImport(srcFilePath, testFilePath);
|
|
397
415
|
const importNames = collectImportNames(scenarios);
|
|
@@ -451,11 +469,45 @@ function buildTestSource(scenarios, srcFilePath, testFilePath, mockResult, proje
|
|
|
451
469
|
// ─── Class describe block with constructor DI ────────────────────────────────
|
|
452
470
|
function buildClassDescribe(className, methods, mockResult, tableThreshold = 0, adapter = (0, frameworkAdapter_1.createAdapter)("jest"), snapshotForComplexTypes = false) {
|
|
453
471
|
const lines = [];
|
|
472
|
+
const isAbstract = methods[0]?.isAbstractClass ?? false;
|
|
473
|
+
const abstractMethods = methods[0]?.abstractMethods ?? [];
|
|
474
|
+
const testClassName = isAbstract ? `Test${className}` : className;
|
|
475
|
+
// For abstract classes, generate a concrete test subclass
|
|
476
|
+
if (isAbstract) {
|
|
477
|
+
lines.push(`// Concrete subclass to test protected methods of abstract ${className}`);
|
|
478
|
+
lines.push(`class ${testClassName} extends ${className} {`);
|
|
479
|
+
// Stub all abstract methods with minimal implementations
|
|
480
|
+
for (const am of abstractMethods) {
|
|
481
|
+
const paramList = am.params.map(p => `_${p.name}: ${p.type}`).join(", ");
|
|
482
|
+
const returnDefault = buildAbstractMethodDefault(am.returnType, am.isAsync);
|
|
483
|
+
if (am.isAsync) {
|
|
484
|
+
lines.push(` async ${am.name}(${paramList}): ${am.returnType} { return ${returnDefault}; }`);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
lines.push(` ${am.name}(${paramList}): ${am.returnType} { return ${returnDefault}; }`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// Expose protected methods via public wrappers
|
|
491
|
+
const protectedMethods = methods.filter(m => !m.isStatic && !abstractMethods.some(am => am.name === m.functionName));
|
|
492
|
+
for (const pm of protectedMethods) {
|
|
493
|
+
const paramList = pm.parameters.map(p => `${p.name}: ${p.type}`).join(", ");
|
|
494
|
+
const argList = pm.parameters.map(p => p.name).join(", ");
|
|
495
|
+
const retType = pm.returnType;
|
|
496
|
+
if (pm.isAsync) {
|
|
497
|
+
lines.push(` async call${capitalize(pm.functionName)}(${paramList}): ${retType} { return this.${pm.functionName}(${argList}); }`);
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
lines.push(` call${capitalize(pm.functionName)}(${paramList}): ${retType} { return this.${pm.functionName}(${argList}); }`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
lines.push("}");
|
|
504
|
+
lines.push("");
|
|
505
|
+
}
|
|
454
506
|
lines.push(`describe(${JSON.stringify(className)}, () => {`);
|
|
455
507
|
// Determine if the class has any instance methods (non-static) before declaring `instance`
|
|
456
508
|
const hasInstanceMethods = methods.some((m) => !m.isStatic);
|
|
457
509
|
if (hasInstanceMethods) {
|
|
458
|
-
lines.push(` let instance: ${
|
|
510
|
+
lines.push(` let instance: ${testClassName};`);
|
|
459
511
|
}
|
|
460
512
|
// Generate mock objects for constructor dependencies
|
|
461
513
|
// E-3: declare as `let` at describe scope, assign fresh instances in beforeEach
|
|
@@ -526,7 +578,11 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
|
|
|
526
578
|
const typeLower = param.type.toLowerCase();
|
|
527
579
|
const asyncFn = `${fn}.mockResolvedValue(null)`;
|
|
528
580
|
const asyncIdFn = `${fn}.mockResolvedValue({ id: 1 })`;
|
|
529
|
-
if (typeLower.includes("
|
|
581
|
+
if (typeLower.includes("configservice") || typeLower.includes("config_service")) {
|
|
582
|
+
// NestJS ConfigService mock — must come before generic "service" match
|
|
583
|
+
beforeEachAssignments.push(` ${mockName} = { get: ${fn}.mockReturnValue('test'), getOrThrow: ${fn}.mockReturnValue('test') } as any;`);
|
|
584
|
+
}
|
|
585
|
+
else if (typeLower.includes("repository") || typeLower.includes("repo")) {
|
|
530
586
|
beforeEachAssignments.push(` ${mockName} = { find: ${fn}.mockResolvedValue([]), findOne: ${asyncFn}, findById: ${asyncFn}, ` +
|
|
531
587
|
`save: ${asyncIdFn}, create: ${asyncIdFn}, update: ${asyncIdFn}, delete: ${asyncIdFn}, ` +
|
|
532
588
|
`createQueryBuilder: ${fn}.mockReturnValue({ where: ${fn}.mockReturnThis(), getOne: ${asyncFn}, getMany: ${fn}.mockResolvedValue([]) }) } as any;`);
|
|
@@ -545,13 +601,8 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
|
|
|
545
601
|
beforeEachAssignments.push(` ${mockName} = { send: ${asyncFn}, emit: ${fn}, publish: ${asyncFn}, add: ${asyncFn} } as any;`);
|
|
546
602
|
}
|
|
547
603
|
else if (typeLower.includes("database") || typeLower.includes("nodepgdatabase") || typeLower.includes("drizzle") || typeLower.includes("pgtransaction")) {
|
|
548
|
-
// Drizzle ORM database/transaction mock
|
|
549
|
-
|
|
550
|
-
beforeEachAssignments.push(` ${mockName} = { select: ${fn}.mockReturnValue({ from: ${chain}, where: ${chain}, leftJoin: ${chain}, orderBy: ${chain}, limit: ${chain}, then: ${fn}.mockResolvedValue([]) }),` +
|
|
551
|
-
` insert: ${fn}.mockReturnValue({ values: ${fn}.mockReturnValue({ onConflictDoNothing: ${fn}.mockReturnValue({ returning: ${fn}.mockResolvedValue([{ id: 1 }]) }), returning: ${fn}.mockResolvedValue([{ id: 1 }]) }) }),` +
|
|
552
|
-
` update: ${fn}.mockReturnValue({ set: ${fn}.mockReturnValue({ where: ${fn}.mockReturnValue({ returning: ${fn}.mockResolvedValue([{ id: 1 }]) }) }) }),` +
|
|
553
|
-
` delete: ${fn}.mockReturnValue({ where: ${fn}.mockResolvedValue([]) }),` +
|
|
554
|
-
` execute: ${asyncFn}, query: ${asyncFn}, transaction: ${fn}.mockImplementation((cb: any) => cb(${mockName})) } as any;`);
|
|
604
|
+
// Drizzle ORM database/transaction mock — Proxy-based self-referencing chain
|
|
605
|
+
beforeEachAssignments.push(` ${mockName} = new Proxy({} as any, { get: (_t, prop) => { if (prop === 'then' || prop === 'catch') return undefined; if (typeof prop === 'string' && !_t[prop]) { if (['select','insert','update','delete','execute','query'].includes(prop)) _t[prop] = ${fn}.mockImplementation(() => new Proxy({} as any, { get: (_c, m) => { if (m === 'then') return (cb: any) => Promise.resolve([]).then(cb); if (typeof m === 'string' && !_c[m]) _c[m] = ${fn}.mockReturnValue(_c); return _c[m]; } })); else if (prop === 'transaction') _t[prop] = ${fn}.mockImplementation((cb: any) => cb(${mockName})); else _t[prop] = ${fn}; } return _t[prop]; } });`);
|
|
555
606
|
}
|
|
556
607
|
else if (typeLower.includes("clickhouse") || typeLower.includes("client")) {
|
|
557
608
|
// ClickHouse / generic DB client mock
|
|
@@ -573,10 +624,24 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
|
|
|
573
624
|
lines.push(decl);
|
|
574
625
|
}
|
|
575
626
|
}
|
|
627
|
+
// Collect env var properties from all methods (they share the same class)
|
|
628
|
+
const envVarProps = methods[0]?.envVarProperties ?? [];
|
|
629
|
+
if (envVarProps.length > 0) {
|
|
630
|
+
lines.push("");
|
|
631
|
+
lines.push(" let originalEnv: NodeJS.ProcessEnv;");
|
|
632
|
+
}
|
|
576
633
|
lines.push("");
|
|
577
634
|
lines.push(" beforeEach(() => {");
|
|
578
635
|
// FA-2: clearAllMocks in beforeEach (keeps implementations, clears call history)
|
|
579
636
|
lines.push(` ${adapter.clearAllMocks()}`);
|
|
637
|
+
// Set up env vars before creating the instance (class properties read env at construction time)
|
|
638
|
+
if (envVarProps.length > 0) {
|
|
639
|
+
lines.push(" originalEnv = { ...process.env };");
|
|
640
|
+
for (const evp of envVarProps) {
|
|
641
|
+
const val = evp.defaultValue ?? "test-value";
|
|
642
|
+
lines.push(` process.env['${evp.envVarName}'] = '${val}';`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
580
645
|
// E-3: recreate fresh mock objects so mutations don't leak between tests
|
|
581
646
|
for (const assignment of beforeEachAssignments) {
|
|
582
647
|
lines.push(assignment);
|
|
@@ -586,10 +651,10 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
|
|
|
586
651
|
const args = ctorParams
|
|
587
652
|
.map((p) => `mock${capitalize(p.name)}`)
|
|
588
653
|
.join(", ");
|
|
589
|
-
lines.push(` instance = new ${
|
|
654
|
+
lines.push(` instance = new ${testClassName}(${args});`);
|
|
590
655
|
}
|
|
591
656
|
else {
|
|
592
|
-
lines.push(` instance = new ${
|
|
657
|
+
lines.push(` instance = new ${testClassName}();`);
|
|
593
658
|
}
|
|
594
659
|
}
|
|
595
660
|
lines.push(" });");
|
|
@@ -597,8 +662,9 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
|
|
|
597
662
|
lines.push(" afterEach(() => {");
|
|
598
663
|
// FA-2: resetAllMocks in afterEach (resets implementations too)
|
|
599
664
|
lines.push(` ${adapter.resetAllMocks()}`);
|
|
600
|
-
|
|
601
|
-
|
|
665
|
+
if (envVarProps.length > 0) {
|
|
666
|
+
lines.push(" process.env = originalEnv;");
|
|
667
|
+
}
|
|
602
668
|
lines.push(" });");
|
|
603
669
|
// T-2: always emit afterAll (not only when ctorParams.length > 0)
|
|
604
670
|
lines.push("");
|
|
@@ -666,7 +732,8 @@ function buildFunctionDescribe(fn, mockResult, tableThreshold = 0, adapter = (0,
|
|
|
666
732
|
lines.push(" });");
|
|
667
733
|
lines.push("");
|
|
668
734
|
}
|
|
669
|
-
const
|
|
735
|
+
const mockKey = fn.className ? `${fn.className}_${fn.functionName}` : fn.functionName;
|
|
736
|
+
const constName = mockResult?.constantNames.get(mockKey);
|
|
670
737
|
const { tableCases, individualCases } = partitionCases(fn.cases, fn.parameters.length, tableThreshold);
|
|
671
738
|
if (tableCases.length > 0) {
|
|
672
739
|
lines.push(indentBlock(buildTableBlock(fn, tableCases, snapshotForComplexTypes), 1));
|
|
@@ -684,7 +751,8 @@ function buildMethodDescribe(fn, mockResult, tableThreshold = 0, adapter = (0, f
|
|
|
684
751
|
const lines = [];
|
|
685
752
|
lines.push(`// @testgen-sig:${buildSignatureFingerprint(fn)}`);
|
|
686
753
|
lines.push(`describe(${JSON.stringify(fn.functionName)}, () => {`);
|
|
687
|
-
const
|
|
754
|
+
const mockKey = fn.className ? `${fn.className}_${fn.functionName}` : fn.functionName;
|
|
755
|
+
const constName = mockResult?.constantNames.get(mockKey);
|
|
688
756
|
const { tableCases, individualCases } = partitionCases(fn.cases, fn.parameters.length, tableThreshold);
|
|
689
757
|
if (tableCases.length > 0) {
|
|
690
758
|
lines.push(indentBlock(buildTableBlock(fn, tableCases, snapshotForComplexTypes), 1));
|
|
@@ -744,7 +812,7 @@ function buildTableBlock(scenario, tableCases, snapshotForComplexTypes = false)
|
|
|
744
812
|
const isStatic = !!scenario.isStatic;
|
|
745
813
|
const caller = isStatic
|
|
746
814
|
? `${scenario.className}.${fnName}`
|
|
747
|
-
: isMethod ? `instance.${fnName}` : fnName;
|
|
815
|
+
: isMethod ? `instance.${scenario.isAbstractClass ? `call${capitalize(fnName)}` : fnName}` : fnName;
|
|
748
816
|
const safeParamNames = paramNames.map(safeName);
|
|
749
817
|
if (isAsync) {
|
|
750
818
|
lines.push(` const result = await ${caller}(${safeParamNames.join(", ")});`);
|
|
@@ -768,7 +836,7 @@ function buildTestBlock(scenario, testCase, mockConstName, adapter = (0, framewo
|
|
|
768
836
|
case "throws":
|
|
769
837
|
return buildThrowsBlock(scenario, testCase, mockConstName);
|
|
770
838
|
case "rejects":
|
|
771
|
-
return buildRejectsBlock(scenario, testCase, mockConstName);
|
|
839
|
+
return buildRejectsBlock(scenario, testCase, mockConstName, adapter);
|
|
772
840
|
default:
|
|
773
841
|
return buildStandardBlock(scenario, testCase, mockConstName, adapter, snapshotForComplexTypes);
|
|
774
842
|
}
|
|
@@ -836,7 +904,7 @@ function buildStandardBlock(scenario, testCase, mockConstName, adapter = (0, fra
|
|
|
836
904
|
const argsStr = testCase.paramNames.map(n => `${safeName(n)} as any`).join(", ");
|
|
837
905
|
const caller = isStatic
|
|
838
906
|
? `${scenario.className}.${fnName}`
|
|
839
|
-
: isMethod ? `instance.${fnName}` : fnName;
|
|
907
|
+
: isMethod ? `instance.${scenario.isAbstractClass ? `call${capitalize(fnName)}` : fnName}` : fnName;
|
|
840
908
|
const callExpr = `${caller}(${argsStr})`;
|
|
841
909
|
if (isAsync) {
|
|
842
910
|
lines.push(` const result = await ${callExpr};`);
|
|
@@ -848,6 +916,7 @@ function buildStandardBlock(scenario, testCase, mockConstName, adapter = (0, fra
|
|
|
848
916
|
lines.push("");
|
|
849
917
|
// ── Assert ──────────────────────────────────────────────────────
|
|
850
918
|
lines.push(" // Assert");
|
|
919
|
+
const assertStartIdx = lines.length;
|
|
851
920
|
if (isDependencyTest) {
|
|
852
921
|
// Verify only functions that the analyzer confirmed are called (have meta)
|
|
853
922
|
// This avoids asserting on imports the function never uses
|
|
@@ -898,23 +967,57 @@ function buildStandardBlock(scenario, testCase, mockConstName, adapter = (0, fra
|
|
|
898
967
|
for (const e of expectLines) {
|
|
899
968
|
lines.push(` ${e}`);
|
|
900
969
|
}
|
|
901
|
-
//
|
|
970
|
+
// Add dependency-call verification for happy-path (valid input) tests
|
|
902
971
|
if (!isDependencyTest && scenario.dependencies.length > 0) {
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
.
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
972
|
+
const isHappyPath = testCase.label.includes("valid") || testCase.label.includes("typical");
|
|
973
|
+
const isClassMethod = !!scenario.className;
|
|
974
|
+
const ctorParams = scenario.constructorParams ?? [];
|
|
975
|
+
if (isHappyPath) {
|
|
976
|
+
lines.push("");
|
|
977
|
+
for (const dep of scenario.dependencies) {
|
|
978
|
+
let mockPrefix = "";
|
|
979
|
+
let skipDep = false;
|
|
980
|
+
if (isClassMethod && ctorParams.length > 0) {
|
|
981
|
+
const depTypeName = dep.importedNames.find(n => /^[A-Z]/.test(n)) ?? dep.localName;
|
|
982
|
+
const matchingParam = ctorParams.find((p) => p.type === depTypeName || p.type.includes(depTypeName));
|
|
983
|
+
if (matchingParam) {
|
|
984
|
+
mockPrefix = `mock${capitalize(matchingParam.name)}.`;
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
skipDep = true;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (skipDep)
|
|
991
|
+
continue;
|
|
992
|
+
const metaList = dep.importedFunctionMeta.length > 0
|
|
993
|
+
? dep.importedFunctionMeta
|
|
994
|
+
: dep.importedNames
|
|
995
|
+
.filter(n => n !== "default" && !/^[A-Z]/.test(n))
|
|
996
|
+
.map(n => ({ name: n, returnType: "unknown", isAsync: false, params: [] }));
|
|
997
|
+
for (const meta of metaList) {
|
|
998
|
+
if (isClassMethod && /^[A-Z]/.test(meta.name) && dep.importedFunctionMeta.length === 0)
|
|
999
|
+
continue;
|
|
1000
|
+
const expectTarget = `${mockPrefix}${meta.name}`;
|
|
1001
|
+
const depParams = meta.params ?? [];
|
|
1002
|
+
if (depParams.length > 0) {
|
|
1003
|
+
const callWithArgs = buildCalledWithArgs(depParams, testCase.paramNames, testCase.argLiterals);
|
|
1004
|
+
lines.push(` expect(${expectTarget}).toHaveBeenCalledWith(${callWithArgs});`);
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
lines.push(` expect(${expectTarget}).toHaveBeenCalled();`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// Fallback: if no assertions were generated, add a meaningful default
|
|
1015
|
+
if (lines.length === assertStartIdx) {
|
|
1016
|
+
if (isAsync) {
|
|
1017
|
+
lines.push(" expect(result).toBeUndefined();");
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
lines.push(" expect(result).toBeDefined();");
|
|
918
1021
|
}
|
|
919
1022
|
}
|
|
920
1023
|
lines.push("});");
|
|
@@ -951,22 +1054,66 @@ function buildThrowsBlock(scenario, testCase, mockConstName) {
|
|
|
951
1054
|
const argsStr = testCase.paramNames.map(n => `${safeName(n)} as any`).join(", ");
|
|
952
1055
|
const caller = isStatic
|
|
953
1056
|
? `${scenario.className}.${fnName}`
|
|
954
|
-
: isMethod ? `instance.${fnName}` : fnName;
|
|
955
|
-
|
|
956
|
-
|
|
1057
|
+
: isMethod ? `instance.${scenario.isAbstractClass ? `call${capitalize(fnName)}` : fnName}` : fnName;
|
|
1058
|
+
// Use specific exception type from scenario label (e.g. "throws BadRequestException")
|
|
1059
|
+
const throwsExType = extractExceptionFromLabel(testCase.label, "throws");
|
|
1060
|
+
const errorMatcher = throwsExType || "Error";
|
|
1061
|
+
lines.push(` expect(() => ${caller}(${argsStr})).toThrow(${errorMatcher});`);
|
|
957
1062
|
lines.push("});");
|
|
958
1063
|
return lines.join("\n");
|
|
959
1064
|
}
|
|
960
1065
|
// ─── Rejects test block (async rejection) ────────────────────────────────────
|
|
961
|
-
function buildRejectsBlock(scenario, testCase, mockConstName) {
|
|
1066
|
+
function buildRejectsBlock(scenario, testCase, mockConstName, adapter = (0, frameworkAdapter_1.createAdapter)("jest")) {
|
|
962
1067
|
const lines = [];
|
|
963
1068
|
const fnName = scenario.functionName;
|
|
964
1069
|
const isMethod = !!scenario.className;
|
|
965
1070
|
const isStatic = !!scenario.isStatic;
|
|
1071
|
+
const isDepFailure = testCase.label === "propagates error when dependency fails";
|
|
966
1072
|
lines.push(`test(${JSON.stringify(testCase.label)}, async () => {`);
|
|
967
1073
|
// ── Arrange ──────────────────────────────────────────────────────
|
|
968
1074
|
const caseKey = camelCase(testCase.label);
|
|
969
|
-
if (
|
|
1075
|
+
if (isDepFailure && scenario.dependencies.length > 0) {
|
|
1076
|
+
// Set up a dependency to reject, triggering the error path
|
|
1077
|
+
lines.push(" // Arrange — force a dependency to reject");
|
|
1078
|
+
const isClassMethod = !!scenario.className;
|
|
1079
|
+
const ctorParams = scenario.constructorParams ?? [];
|
|
1080
|
+
// Find the first async dependency to make it fail
|
|
1081
|
+
let arranged = false;
|
|
1082
|
+
for (const dep of scenario.dependencies) {
|
|
1083
|
+
if (arranged)
|
|
1084
|
+
break;
|
|
1085
|
+
let mockPrefix = "";
|
|
1086
|
+
if (isClassMethod && ctorParams.length > 0) {
|
|
1087
|
+
const depTypeName = dep.importedNames.find(n => /^[A-Z]/.test(n)) ?? dep.localName;
|
|
1088
|
+
const matchingParam = ctorParams.find((p) => p.type === depTypeName || p.type.includes(depTypeName));
|
|
1089
|
+
if (matchingParam) {
|
|
1090
|
+
mockPrefix = `mock${capitalize(matchingParam.name)}.`;
|
|
1091
|
+
}
|
|
1092
|
+
else {
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
for (const meta of dep.importedFunctionMeta) {
|
|
1097
|
+
if (meta.isAsync) {
|
|
1098
|
+
lines.push(` const depError = new Error('dependency failure');`);
|
|
1099
|
+
lines.push(` ${mockPrefix}${meta.name}.mockRejectedValueOnce(depError);`);
|
|
1100
|
+
arranged = true;
|
|
1101
|
+
break;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (!arranged) {
|
|
1106
|
+
lines.push(` // No async dependency found — add a mockRejectedValueOnce call for the relevant dep`);
|
|
1107
|
+
}
|
|
1108
|
+
// Declare input params
|
|
1109
|
+
if (testCase.paramNames.length > 0 && testCase.argLiterals.length > 0) {
|
|
1110
|
+
for (let i = 0; i < testCase.paramNames.length; i++) {
|
|
1111
|
+
lines.push(` const ${safeName(testCase.paramNames[i])} = ${testCase.argLiterals[i] ?? "undefined"};`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
lines.push("");
|
|
1115
|
+
}
|
|
1116
|
+
else if (testCase.paramNames.length > 0 && mockConstName) {
|
|
970
1117
|
lines.push(" // Arrange");
|
|
971
1118
|
const mockRef = `mocks.${mockConstName}.${caseKey}`;
|
|
972
1119
|
for (const name of testCase.paramNames) {
|
|
@@ -988,12 +1135,35 @@ function buildRejectsBlock(scenario, testCase, mockConstName) {
|
|
|
988
1135
|
const argsStr = testCase.paramNames.map(n => `${safeName(n)} as any`).join(", ");
|
|
989
1136
|
const caller = isStatic
|
|
990
1137
|
? `${scenario.className}.${fnName}`
|
|
991
|
-
: isMethod ? `instance.${fnName}` : fnName;
|
|
992
|
-
|
|
993
|
-
|
|
1138
|
+
: isMethod ? `instance.${scenario.isAbstractClass ? `call${capitalize(fnName)}` : fnName}` : fnName;
|
|
1139
|
+
// Use specific exception type from scenario label (e.g. "rejects with NotFoundException")
|
|
1140
|
+
const rejectsExType = extractExceptionFromLabel(testCase.label, "rejects with");
|
|
1141
|
+
if (isDepFailure) {
|
|
1142
|
+
lines.push(` await expect(${caller}(${argsStr})).rejects.toThrow('dependency failure');`);
|
|
1143
|
+
}
|
|
1144
|
+
else if (rejectsExType) {
|
|
1145
|
+
lines.push(` await expect(${caller}(${argsStr})).rejects.toThrow(${rejectsExType});`);
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
lines.push(` await expect(${caller}(${argsStr})).rejects.toThrow();`);
|
|
1149
|
+
}
|
|
994
1150
|
lines.push("});");
|
|
995
1151
|
return lines.join("\n");
|
|
996
1152
|
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Extract an exception class name from a scenario label.
|
|
1155
|
+
* e.g. "throws BadRequestException" → "BadRequestException"
|
|
1156
|
+
* "rejects with NotFoundException" → "NotFoundException"
|
|
1157
|
+
*/
|
|
1158
|
+
function extractExceptionFromLabel(label, prefix) {
|
|
1159
|
+
if (!label.startsWith(prefix))
|
|
1160
|
+
return null;
|
|
1161
|
+
const rest = label.slice(prefix.length).trim();
|
|
1162
|
+
// Must be a PascalCase identifier (exception class name)
|
|
1163
|
+
if (/^[A-Z][a-zA-Z0-9]*$/.test(rest))
|
|
1164
|
+
return rest;
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
997
1167
|
// ─── Mock return value builder ────────────────────────────────────────────────
|
|
998
1168
|
/**
|
|
999
1169
|
* Given a ts-morph return type string (e.g. "Promise<User>" or "string[]"),
|
|
@@ -1028,6 +1198,27 @@ function buildMockReturnValue(returnType) {
|
|
|
1028
1198
|
// Object / interface / class type — generate a minimal shape
|
|
1029
1199
|
return "{ id: 1 }";
|
|
1030
1200
|
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Generate a minimal default return value for abstract method stubs.
|
|
1203
|
+
* Used when generating concrete subclasses for abstract class testing.
|
|
1204
|
+
*/
|
|
1205
|
+
function buildAbstractMethodDefault(returnType, isAsync) {
|
|
1206
|
+
const raw = returnType.trim();
|
|
1207
|
+
// Unwrap Promise<T> for async methods
|
|
1208
|
+
const promiseMatch = raw.match(/^Promise<(.+)>$/i);
|
|
1209
|
+
const inner = (promiseMatch ? promiseMatch[1] : raw).trim().toLowerCase();
|
|
1210
|
+
if (inner === "void" || inner === "undefined")
|
|
1211
|
+
return isAsync ? "undefined as any" : "undefined as any";
|
|
1212
|
+
if (inner === "boolean")
|
|
1213
|
+
return "true";
|
|
1214
|
+
if (inner === "number")
|
|
1215
|
+
return "0";
|
|
1216
|
+
if (inner === "string")
|
|
1217
|
+
return "''";
|
|
1218
|
+
if (inner.endsWith("[]") || inner.startsWith("array<"))
|
|
1219
|
+
return "[] as any";
|
|
1220
|
+
return "{} as any";
|
|
1221
|
+
}
|
|
1031
1222
|
// ─── Expect stubs based on return type ───────────────────────────────────────
|
|
1032
1223
|
/** Primitive/built-in type names that do NOT warrant snapshot assertions. */
|
|
1033
1224
|
const SNAPSHOT_SKIP_TYPES = new Set([
|
|
@@ -1048,52 +1239,67 @@ function buildExpectStubs(returnType, scenarioLabel, snapshotForComplexTypes = f
|
|
|
1048
1239
|
const t = returnType.trim().toLowerCase();
|
|
1049
1240
|
const unwrapped = unwrapType(t);
|
|
1050
1241
|
const stubs = [];
|
|
1051
|
-
|
|
1242
|
+
// Detect nullable return types (T | null) — guard/empty-input scenarios should expect null
|
|
1243
|
+
const isNullable = unwrapped.includes("| null") || unwrapped.includes("null |");
|
|
1244
|
+
const isGuardCase = scenarioLabel.includes("invalid") || scenarioLabel.includes("empty") || scenarioLabel.includes("guard") || scenarioLabel.includes("is 0");
|
|
1245
|
+
if (isNullable && isGuardCase) {
|
|
1246
|
+
stubs.push("expect(result).toBeNull();");
|
|
1247
|
+
return stubs;
|
|
1248
|
+
}
|
|
1249
|
+
// Handle null / void / undefined early — no mock return value needed
|
|
1250
|
+
if (unwrapped === "null") {
|
|
1251
|
+
stubs.push("expect(result).toBeNull();");
|
|
1252
|
+
return stubs;
|
|
1253
|
+
}
|
|
1254
|
+
if (unwrapped === "void" || unwrapped === "undefined") {
|
|
1255
|
+
stubs.push("expect(result).toBeUndefined();");
|
|
1256
|
+
return stubs;
|
|
1257
|
+
}
|
|
1258
|
+
// Derive the concrete expected value from the return type (mirrors mockReturnValue / mockResolvedValue)
|
|
1259
|
+
const expectedValue = buildMockReturnValue(returnType);
|
|
1052
1260
|
if (unwrapped === "number") {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
if (scenarioLabel.includes("zero"))
|
|
1057
|
-
stubs.push("
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1261
|
+
if (scenarioLabel.includes("negative")) {
|
|
1262
|
+
stubs.push("expect(result).toBeLessThan(0);");
|
|
1263
|
+
}
|
|
1264
|
+
else if (scenarioLabel.includes("zero")) {
|
|
1265
|
+
stubs.push("expect(result).toBe(0);");
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
stubs.push(`expect(result).toBe(${expectedValue});`);
|
|
1269
|
+
}
|
|
1061
1270
|
}
|
|
1062
1271
|
else if (unwrapped === "string") {
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
stubs.push(
|
|
1068
|
-
|
|
1272
|
+
if (scenarioLabel.includes("empty")) {
|
|
1273
|
+
stubs.push("expect(result).toBe('');");
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
stubs.push(`expect(result).toBe(${expectedValue});`);
|
|
1277
|
+
}
|
|
1069
1278
|
}
|
|
1070
1279
|
else if (unwrapped === "boolean") {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
if (scenarioLabel.includes("falsy"))
|
|
1075
|
-
stubs.push("
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
stubs.push("expect(result).toBeNull();");
|
|
1081
|
-
}
|
|
1082
|
-
else if (unwrapped === "void" || unwrapped === "undefined") {
|
|
1083
|
-
stubs.length = 0;
|
|
1084
|
-
stubs.push("expect(result).toBeUndefined();");
|
|
1280
|
+
if (scenarioLabel.includes("truthy")) {
|
|
1281
|
+
stubs.push("expect(result).toBe(true);");
|
|
1282
|
+
}
|
|
1283
|
+
else if (scenarioLabel.includes("falsy")) {
|
|
1284
|
+
stubs.push("expect(result).toBe(false);");
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
stubs.push(`expect(result).toBe(${expectedValue});`);
|
|
1288
|
+
}
|
|
1085
1289
|
}
|
|
1086
1290
|
else if (unwrapped.endsWith("[]") || unwrapped.startsWith("array<")) {
|
|
1087
1291
|
stubs.push("expect(Array.isArray(result)).toBe(true);");
|
|
1088
|
-
if (scenarioLabel.includes("empty"))
|
|
1089
|
-
stubs.push("
|
|
1090
|
-
|
|
1292
|
+
if (scenarioLabel.includes("empty")) {
|
|
1293
|
+
stubs.push("expect(result).toHaveLength(0);");
|
|
1294
|
+
}
|
|
1295
|
+
stubs.push(`expect(result).toEqual(${expectedValue});`);
|
|
1091
1296
|
}
|
|
1092
1297
|
else if (/ApiResponseDto/i.test(returnType)) {
|
|
1093
1298
|
// Controller methods wrapped in ApiResponseDto<T>
|
|
1094
|
-
stubs.push("expect(result).
|
|
1095
|
-
stubs.push("expect
|
|
1096
|
-
stubs.push("
|
|
1299
|
+
stubs.push("expect(result).toEqual(expect.objectContaining({");
|
|
1300
|
+
stubs.push(" success: expect.any(Boolean),");
|
|
1301
|
+
stubs.push(" data: expect.anything(),");
|
|
1302
|
+
stubs.push("}));");
|
|
1097
1303
|
}
|
|
1098
1304
|
else {
|
|
1099
1305
|
// Try to parse as an inline object type: { id: string; status: string; ... }
|
|
@@ -1104,14 +1310,9 @@ function buildExpectStubs(returnType, scenarioLabel, snapshotForComplexTypes = f
|
|
|
1104
1310
|
stubs.push(` ${prop.name}: ${tsTypeToJestMatcher(prop.tsType)},`);
|
|
1105
1311
|
}
|
|
1106
1312
|
stubs.push("});");
|
|
1107
|
-
stubs.push("// TODO: assert exact values, e.g. expect(result.id).toBe(expectedId)");
|
|
1108
1313
|
}
|
|
1109
1314
|
else {
|
|
1110
|
-
stubs.push(
|
|
1111
|
-
if (snapshotForComplexTypes && isComplexReturnType(unwrapped)) {
|
|
1112
|
-
stubs.push("expect(result).toMatchSnapshot(); // Update snapshot after first run");
|
|
1113
|
-
}
|
|
1114
|
-
stubs.push("// TODO: add domain assertion — e.g. expect(result).toEqual(expected) or toHaveProperty('key', value)");
|
|
1315
|
+
stubs.push(`expect(result).toEqual(${expectedValue});`);
|
|
1115
1316
|
}
|
|
1116
1317
|
}
|
|
1117
1318
|
return stubs;
|
|
@@ -1261,6 +1462,9 @@ function computeRelativeImport(targetFilePath, fromFilePath) {
|
|
|
1261
1462
|
if (!rel.startsWith("."))
|
|
1262
1463
|
rel = "./" + rel;
|
|
1263
1464
|
const basename = path.basename(targetFilePath, ".ts");
|
|
1465
|
+
// Avoid double-slash when same directory (rel === "./")
|
|
1466
|
+
if (rel === ".")
|
|
1467
|
+
return `./${basename}`;
|
|
1264
1468
|
return `${rel}/${basename}`;
|
|
1265
1469
|
}
|
|
1266
1470
|
// ─── Utility helpers ─────────────────────────────────────────────────────────
|
|
@@ -1288,11 +1492,33 @@ function capitalize(str) {
|
|
|
1288
1492
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1289
1493
|
}
|
|
1290
1494
|
// ─── Constructor dependency resolution ───────────────────────────────────────
|
|
1291
|
-
/** Names to skip when resolving class methods — lifecycle hooks, internals */
|
|
1495
|
+
/** Names to skip when resolving class methods — lifecycle hooks, internals, built-in prototypes */
|
|
1292
1496
|
const SKIP_METHOD_NAMES = new Set([
|
|
1497
|
+
// Object prototype
|
|
1293
1498
|
"constructor", "toString", "valueOf", "hasOwnProperty",
|
|
1499
|
+
"isPrototypeOf", "propertyIsEnumerable", "toLocaleString",
|
|
1500
|
+
// NestJS lifecycle hooks
|
|
1294
1501
|
"onModuleInit", "onModuleDestroy", "onApplicationBootstrap",
|
|
1295
1502
|
"onApplicationShutdown", "beforeApplicationShutdown",
|
|
1503
|
+
// String prototype methods
|
|
1504
|
+
"charAt", "charCodeAt", "codePointAt", "concat", "indexOf",
|
|
1505
|
+
"lastIndexOf", "localeCompare", "match", "matchAll", "replace",
|
|
1506
|
+
"replaceAll", "search", "slice", "split", "substring", "substr",
|
|
1507
|
+
"toLowerCase", "toLocaleLowerCase", "toUpperCase", "toLocaleUpperCase",
|
|
1508
|
+
"trim", "trimStart", "trimEnd", "trimLeft", "trimRight",
|
|
1509
|
+
"padStart", "padEnd", "repeat", "normalize", "includes",
|
|
1510
|
+
"startsWith", "endsWith", "at", "anchor", "big", "blink",
|
|
1511
|
+
"bold", "fixed", "fontcolor", "fontsize", "italics", "link",
|
|
1512
|
+
"small", "strike", "sub", "sup",
|
|
1513
|
+
// Array prototype methods
|
|
1514
|
+
"push", "pop", "shift", "unshift", "splice", "sort", "reverse",
|
|
1515
|
+
"map", "filter", "reduce", "reduceRight", "forEach", "some",
|
|
1516
|
+
"every", "find", "findIndex", "findLast", "findLastIndex",
|
|
1517
|
+
"flat", "flatMap", "fill", "copyWithin", "entries", "keys",
|
|
1518
|
+
"values", "join",
|
|
1519
|
+
// Number / Symbol / common built-ins
|
|
1520
|
+
"toFixed", "toPrecision", "toExponential",
|
|
1521
|
+
"length", "size",
|
|
1296
1522
|
]);
|
|
1297
1523
|
/**
|
|
1298
1524
|
* For constructor-injected dependencies, resolve their actual public method
|