@gnapi/cotester 1.2.10 → 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/generator.js CHANGED
@@ -37,6 +37,7 @@ exports.VERSION_MARKER = exports.CURRENT_FILE_VERSION = exports.TESTGEN_MARKER =
37
37
  exports.generateTestFile = generateTestFile;
38
38
  const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
+ const ts_morph_1 = require("ts-morph");
40
41
  const formatter_1 = require("./formatter");
41
42
  const utils_1 = require("./utils");
42
43
  const ormMockGenerator_1 = require("./ormMockGenerator");
@@ -391,6 +392,24 @@ function buildTestSource(scenarios, srcFilePath, testFilePath, mockResult, proje
391
392
  lines.push(adapter.mockModule(depImportPath, dep.importedNames));
392
393
  lines.push("");
393
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
+ }
394
413
  // ── Import the source module ────────────────────────────────────
395
414
  const importPath = computeRelativeImport(srcFilePath, testFilePath);
396
415
  const importNames = collectImportNames(scenarios);
@@ -450,11 +469,45 @@ function buildTestSource(scenarios, srcFilePath, testFilePath, mockResult, proje
450
469
  // ─── Class describe block with constructor DI ────────────────────────────────
451
470
  function buildClassDescribe(className, methods, mockResult, tableThreshold = 0, adapter = (0, frameworkAdapter_1.createAdapter)("jest"), snapshotForComplexTypes = false) {
452
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
+ }
453
506
  lines.push(`describe(${JSON.stringify(className)}, () => {`);
454
507
  // Determine if the class has any instance methods (non-static) before declaring `instance`
455
508
  const hasInstanceMethods = methods.some((m) => !m.isStatic);
456
509
  if (hasInstanceMethods) {
457
- lines.push(` let instance: ${className};`);
510
+ lines.push(` let instance: ${testClassName};`);
458
511
  }
459
512
  // Generate mock objects for constructor dependencies
460
513
  // E-3: declare as `let` at describe scope, assign fresh instances in beforeEach
@@ -479,6 +532,14 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
479
532
  }
480
533
  }
481
534
  }
535
+ // For constructor-injected dependencies whose methods weren't found via
536
+ // the body-text dependency scan (because the class name doesn't appear in
537
+ // method bodies — only `this.<paramName>.method()` does), resolve the
538
+ // actual public methods from the injected class using ts-morph.
539
+ const sourceFilePath = methods[0]?.sourceFilePath;
540
+ if (sourceFilePath && ctorParams.length > 0) {
541
+ resolveCtorDepMethods(ctorParams, depMethodsByType, sourceFilePath);
542
+ }
482
543
  for (const param of ctorParams) {
483
544
  const mockName = `mock${capitalize(param.name)}`;
484
545
  letDeclarations.push(` let ${mockName}: any;`);
@@ -494,8 +555,15 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
494
555
  }
495
556
  else {
496
557
  const fn = adapter.mockFn();
497
- // Check if we have actual dependency metadata for this param's type
498
- const knownMethods = depMethodsByType.get(param.type);
558
+ // Check if we have actual dependency metadata for this param's type.
559
+ // param.type may be a fully-qualified import path like
560
+ // import("../metadata-sync/metadata.repository").MetadataRepository
561
+ // so we also try matching by the short class name extracted from the end.
562
+ let knownMethods = depMethodsByType.get(param.type);
563
+ if (!knownMethods || knownMethods.size === 0) {
564
+ const shortType = param.type.replace(/^import\([^)]*\)\./, "").replace(/<.*>$/, "").trim();
565
+ knownMethods = depMethodsByType.get(shortType);
566
+ }
499
567
  if (knownMethods && knownMethods.size > 0) {
500
568
  // Use actual method names from AST analysis
501
569
  const mockProps = Array.from(knownMethods).map(m => {
@@ -510,7 +578,11 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
510
578
  const typeLower = param.type.toLowerCase();
511
579
  const asyncFn = `${fn}.mockResolvedValue(null)`;
512
580
  const asyncIdFn = `${fn}.mockResolvedValue({ id: 1 })`;
513
- if (typeLower.includes("repository") || typeLower.includes("repo")) {
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")) {
514
586
  beforeEachAssignments.push(` ${mockName} = { find: ${fn}.mockResolvedValue([]), findOne: ${asyncFn}, findById: ${asyncFn}, ` +
515
587
  `save: ${asyncIdFn}, create: ${asyncIdFn}, update: ${asyncIdFn}, delete: ${asyncIdFn}, ` +
516
588
  `createQueryBuilder: ${fn}.mockReturnValue({ where: ${fn}.mockReturnThis(), getOne: ${asyncFn}, getMany: ${fn}.mockResolvedValue([]) }) } as any;`);
@@ -529,13 +601,8 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
529
601
  beforeEachAssignments.push(` ${mockName} = { send: ${asyncFn}, emit: ${fn}, publish: ${asyncFn}, add: ${asyncFn} } as any;`);
530
602
  }
531
603
  else if (typeLower.includes("database") || typeLower.includes("nodepgdatabase") || typeLower.includes("drizzle") || typeLower.includes("pgtransaction")) {
532
- // Drizzle ORM database/transaction mock with chainable query builder
533
- const chain = `${fn}.mockReturnThis()`;
534
- beforeEachAssignments.push(` ${mockName} = { select: ${fn}.mockReturnValue({ from: ${chain}, where: ${chain}, leftJoin: ${chain}, orderBy: ${chain}, limit: ${chain}, then: ${fn}.mockResolvedValue([]) }),` +
535
- ` insert: ${fn}.mockReturnValue({ values: ${fn}.mockReturnValue({ onConflictDoNothing: ${fn}.mockReturnValue({ returning: ${fn}.mockResolvedValue([{ id: 1 }]) }), returning: ${fn}.mockResolvedValue([{ id: 1 }]) }) }),` +
536
- ` update: ${fn}.mockReturnValue({ set: ${fn}.mockReturnValue({ where: ${fn}.mockReturnValue({ returning: ${fn}.mockResolvedValue([{ id: 1 }]) }) }) }),` +
537
- ` delete: ${fn}.mockReturnValue({ where: ${fn}.mockResolvedValue([]) }),` +
538
- ` 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]; } });`);
539
606
  }
540
607
  else if (typeLower.includes("clickhouse") || typeLower.includes("client")) {
541
608
  // ClickHouse / generic DB client mock
@@ -557,10 +624,24 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
557
624
  lines.push(decl);
558
625
  }
559
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
+ }
560
633
  lines.push("");
561
634
  lines.push(" beforeEach(() => {");
562
635
  // FA-2: clearAllMocks in beforeEach (keeps implementations, clears call history)
563
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
+ }
564
645
  // E-3: recreate fresh mock objects so mutations don't leak between tests
565
646
  for (const assignment of beforeEachAssignments) {
566
647
  lines.push(assignment);
@@ -570,10 +651,10 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
570
651
  const args = ctorParams
571
652
  .map((p) => `mock${capitalize(p.name)}`)
572
653
  .join(", ");
573
- lines.push(` instance = new ${className}(${args});`);
654
+ lines.push(` instance = new ${testClassName}(${args});`);
574
655
  }
575
656
  else {
576
- lines.push(` instance = new ${className}();`);
657
+ lines.push(` instance = new ${testClassName}();`);
577
658
  }
578
659
  }
579
660
  lines.push(" });");
@@ -581,8 +662,9 @@ function buildClassDescribe(className, methods, mockResult, tableThreshold = 0,
581
662
  lines.push(" afterEach(() => {");
582
663
  // FA-2: resetAllMocks in afterEach (resets implementations too)
583
664
  lines.push(` ${adapter.resetAllMocks()}`);
584
- // T-3: TODO for module-level singleton/cache reset
585
- lines.push(" // TODO: reset any module-level singletons or caches if the SUT uses them");
665
+ if (envVarProps.length > 0) {
666
+ lines.push(" process.env = originalEnv;");
667
+ }
586
668
  lines.push(" });");
587
669
  // T-2: always emit afterAll (not only when ctorParams.length > 0)
588
670
  lines.push("");
@@ -650,7 +732,8 @@ function buildFunctionDescribe(fn, mockResult, tableThreshold = 0, adapter = (0,
650
732
  lines.push(" });");
651
733
  lines.push("");
652
734
  }
653
- const constName = mockResult?.constantNames.get(fn.functionName);
735
+ const mockKey = fn.className ? `${fn.className}_${fn.functionName}` : fn.functionName;
736
+ const constName = mockResult?.constantNames.get(mockKey);
654
737
  const { tableCases, individualCases } = partitionCases(fn.cases, fn.parameters.length, tableThreshold);
655
738
  if (tableCases.length > 0) {
656
739
  lines.push(indentBlock(buildTableBlock(fn, tableCases, snapshotForComplexTypes), 1));
@@ -668,7 +751,8 @@ function buildMethodDescribe(fn, mockResult, tableThreshold = 0, adapter = (0, f
668
751
  const lines = [];
669
752
  lines.push(`// @testgen-sig:${buildSignatureFingerprint(fn)}`);
670
753
  lines.push(`describe(${JSON.stringify(fn.functionName)}, () => {`);
671
- const constName = mockResult?.constantNames.get(fn.functionName);
754
+ const mockKey = fn.className ? `${fn.className}_${fn.functionName}` : fn.functionName;
755
+ const constName = mockResult?.constantNames.get(mockKey);
672
756
  const { tableCases, individualCases } = partitionCases(fn.cases, fn.parameters.length, tableThreshold);
673
757
  if (tableCases.length > 0) {
674
758
  lines.push(indentBlock(buildTableBlock(fn, tableCases, snapshotForComplexTypes), 1));
@@ -728,7 +812,7 @@ function buildTableBlock(scenario, tableCases, snapshotForComplexTypes = false)
728
812
  const isStatic = !!scenario.isStatic;
729
813
  const caller = isStatic
730
814
  ? `${scenario.className}.${fnName}`
731
- : isMethod ? `instance.${fnName}` : fnName;
815
+ : isMethod ? `instance.${scenario.isAbstractClass ? `call${capitalize(fnName)}` : fnName}` : fnName;
732
816
  const safeParamNames = paramNames.map(safeName);
733
817
  if (isAsync) {
734
818
  lines.push(` const result = await ${caller}(${safeParamNames.join(", ")});`);
@@ -752,7 +836,7 @@ function buildTestBlock(scenario, testCase, mockConstName, adapter = (0, framewo
752
836
  case "throws":
753
837
  return buildThrowsBlock(scenario, testCase, mockConstName);
754
838
  case "rejects":
755
- return buildRejectsBlock(scenario, testCase, mockConstName);
839
+ return buildRejectsBlock(scenario, testCase, mockConstName, adapter);
756
840
  default:
757
841
  return buildStandardBlock(scenario, testCase, mockConstName, adapter, snapshotForComplexTypes);
758
842
  }
@@ -820,7 +904,7 @@ function buildStandardBlock(scenario, testCase, mockConstName, adapter = (0, fra
820
904
  const argsStr = testCase.paramNames.map(n => `${safeName(n)} as any`).join(", ");
821
905
  const caller = isStatic
822
906
  ? `${scenario.className}.${fnName}`
823
- : isMethod ? `instance.${fnName}` : fnName;
907
+ : isMethod ? `instance.${scenario.isAbstractClass ? `call${capitalize(fnName)}` : fnName}` : fnName;
824
908
  const callExpr = `${caller}(${argsStr})`;
825
909
  if (isAsync) {
826
910
  lines.push(` const result = await ${callExpr};`);
@@ -832,6 +916,7 @@ function buildStandardBlock(scenario, testCase, mockConstName, adapter = (0, fra
832
916
  lines.push("");
833
917
  // ── Assert ──────────────────────────────────────────────────────
834
918
  lines.push(" // Assert");
919
+ const assertStartIdx = lines.length;
835
920
  if (isDependencyTest) {
836
921
  // Verify only functions that the analyzer confirmed are called (have meta)
837
922
  // This avoids asserting on imports the function never uses
@@ -882,23 +967,57 @@ function buildStandardBlock(scenario, testCase, mockConstName, adapter = (0, fra
882
967
  for (const e of expectLines) {
883
968
  lines.push(` ${e}`);
884
969
  }
885
- // Improvement 3: add a commented dep-call verification tip for non-dep tests
970
+ // Add dependency-call verification for happy-path (valid input) tests
886
971
  if (!isDependencyTest && scenario.dependencies.length > 0) {
887
- const firstDep = scenario.dependencies[0];
888
- // Derive a mock variable name from the module path (e.g. "dashboard.service" → "mockDashboardService")
889
- const lastSegment = firstDep.modulePath.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "dependency";
890
- const mockVarName = "mock" + lastSegment
891
- .split(/[-_.]/)
892
- .map(s => s.charAt(0).toUpperCase() + s.slice(1))
893
- .join("");
894
- // Pick the first known method name, or fall back to a generic placeholder
895
- const methodName = firstDep.importedFunctionMeta[0]?.name
896
- ?? firstDep.importedNames.find(n => n !== "default")
897
- ?? "method";
898
- lines.push("");
899
- lines.push(` // Tip: verify a specific dependency was invoked:`);
900
- lines.push(` // expect(${mockVarName}.${methodName}).toHaveBeenCalledTimes(1);`);
901
- lines.push(` // expect(${mockVarName}.${methodName}).toHaveBeenCalledWith(expect.objectContaining({ ... }));`);
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();");
902
1021
  }
903
1022
  }
904
1023
  lines.push("});");
@@ -935,22 +1054,66 @@ function buildThrowsBlock(scenario, testCase, mockConstName) {
935
1054
  const argsStr = testCase.paramNames.map(n => `${safeName(n)} as any`).join(", ");
936
1055
  const caller = isStatic
937
1056
  ? `${scenario.className}.${fnName}`
938
- : isMethod ? `instance.${fnName}` : fnName;
939
- lines.push(` expect(() => ${caller}(${argsStr})).toThrow(Error);`);
940
- lines.push(` // TODO: narrow to a specific error type, e.g. .toThrow(ValidationError) or .toThrow('message')`);
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});`);
941
1062
  lines.push("});");
942
1063
  return lines.join("\n");
943
1064
  }
944
1065
  // ─── Rejects test block (async rejection) ────────────────────────────────────
945
- function buildRejectsBlock(scenario, testCase, mockConstName) {
1066
+ function buildRejectsBlock(scenario, testCase, mockConstName, adapter = (0, frameworkAdapter_1.createAdapter)("jest")) {
946
1067
  const lines = [];
947
1068
  const fnName = scenario.functionName;
948
1069
  const isMethod = !!scenario.className;
949
1070
  const isStatic = !!scenario.isStatic;
1071
+ const isDepFailure = testCase.label === "propagates error when dependency fails";
950
1072
  lines.push(`test(${JSON.stringify(testCase.label)}, async () => {`);
951
1073
  // ── Arrange ──────────────────────────────────────────────────────
952
1074
  const caseKey = camelCase(testCase.label);
953
- if (testCase.paramNames.length > 0 && mockConstName) {
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) {
954
1117
  lines.push(" // Arrange");
955
1118
  const mockRef = `mocks.${mockConstName}.${caseKey}`;
956
1119
  for (const name of testCase.paramNames) {
@@ -972,12 +1135,35 @@ function buildRejectsBlock(scenario, testCase, mockConstName) {
972
1135
  const argsStr = testCase.paramNames.map(n => `${safeName(n)} as any`).join(", ");
973
1136
  const caller = isStatic
974
1137
  ? `${scenario.className}.${fnName}`
975
- : isMethod ? `instance.${fnName}` : fnName;
976
- lines.push(` await expect(${caller}(${argsStr})).rejects.toThrow();`);
977
- lines.push(` // TODO: narrow rejection, e.g. .rejects.toThrow(SpecificError) or .rejects.toThrow('message')`);
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
+ }
978
1150
  lines.push("});");
979
1151
  return lines.join("\n");
980
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
+ }
981
1167
  // ─── Mock return value builder ────────────────────────────────────────────────
982
1168
  /**
983
1169
  * Given a ts-morph return type string (e.g. "Promise<User>" or "string[]"),
@@ -1012,6 +1198,27 @@ function buildMockReturnValue(returnType) {
1012
1198
  // Object / interface / class type — generate a minimal shape
1013
1199
  return "{ id: 1 }";
1014
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
+ }
1015
1222
  // ─── Expect stubs based on return type ───────────────────────────────────────
1016
1223
  /** Primitive/built-in type names that do NOT warrant snapshot assertions. */
1017
1224
  const SNAPSHOT_SKIP_TYPES = new Set([
@@ -1032,52 +1239,67 @@ function buildExpectStubs(returnType, scenarioLabel, snapshotForComplexTypes = f
1032
1239
  const t = returnType.trim().toLowerCase();
1033
1240
  const unwrapped = unwrapType(t);
1034
1241
  const stubs = [];
1035
- stubs.push("expect(result).toBeDefined();");
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);
1036
1260
  if (unwrapped === "number") {
1037
- stubs.push("expect(typeof result).toBe('number');");
1038
- if (scenarioLabel.includes("negative"))
1039
- stubs.push("// expect(result).toBeLessThan(0);");
1040
- if (scenarioLabel.includes("zero"))
1041
- stubs.push("// expect(result).toBe(0);");
1042
- if (scenarioLabel.includes("boundary"))
1043
- stubs.push("expect(Number.isFinite(result)).toBe(true);");
1044
- stubs.push("// TODO: add domain assertion — e.g. expect(result).toBe(expectedValue)");
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
+ }
1045
1270
  }
1046
1271
  else if (unwrapped === "string") {
1047
- stubs.push("expect(typeof result).toBe('string');");
1048
- if (scenarioLabel.includes("empty"))
1049
- stubs.push("// expect(result).toBe('');");
1050
- if (scenarioLabel.includes("valid"))
1051
- stubs.push("expect(result.length).toBeGreaterThan(0);");
1052
- stubs.push("// TODO: add domain assertion — e.g. expect(result).toEqual(expectedString)");
1272
+ if (scenarioLabel.includes("empty")) {
1273
+ stubs.push("expect(result).toBe('');");
1274
+ }
1275
+ else {
1276
+ stubs.push(`expect(result).toBe(${expectedValue});`);
1277
+ }
1053
1278
  }
1054
1279
  else if (unwrapped === "boolean") {
1055
- stubs.push("expect(typeof result).toBe('boolean');");
1056
- if (scenarioLabel.includes("truthy"))
1057
- stubs.push("// expect(result).toBe(true);");
1058
- if (scenarioLabel.includes("falsy"))
1059
- stubs.push("// expect(result).toBe(false);");
1060
- stubs.push("// TODO: add domain assertion — e.g. expect(result).toBe(true)");
1061
- }
1062
- else if (unwrapped === "null") {
1063
- stubs.length = 0;
1064
- stubs.push("expect(result).toBeNull();");
1065
- }
1066
- else if (unwrapped === "void" || unwrapped === "undefined") {
1067
- stubs.length = 0;
1068
- 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
+ }
1069
1289
  }
1070
1290
  else if (unwrapped.endsWith("[]") || unwrapped.startsWith("array<")) {
1071
1291
  stubs.push("expect(Array.isArray(result)).toBe(true);");
1072
- if (scenarioLabel.includes("empty"))
1073
- stubs.push("// expect(result).toHaveLength(0);");
1074
- stubs.push("// TODO: add domain assertion — e.g. expect(result).toHaveLength(n) or toContainEqual(item)");
1292
+ if (scenarioLabel.includes("empty")) {
1293
+ stubs.push("expect(result).toHaveLength(0);");
1294
+ }
1295
+ stubs.push(`expect(result).toEqual(${expectedValue});`);
1075
1296
  }
1076
1297
  else if (/ApiResponseDto/i.test(returnType)) {
1077
1298
  // Controller methods wrapped in ApiResponseDto<T>
1078
- stubs.push("expect(result).toHaveProperty('success');");
1079
- stubs.push("expect(result).toHaveProperty('data');");
1080
- stubs.push("// TODO: assert result.success === true and inspect result.data shape");
1299
+ stubs.push("expect(result).toEqual(expect.objectContaining({");
1300
+ stubs.push(" success: expect.any(Boolean),");
1301
+ stubs.push(" data: expect.anything(),");
1302
+ stubs.push("}));");
1081
1303
  }
1082
1304
  else {
1083
1305
  // Try to parse as an inline object type: { id: string; status: string; ... }
@@ -1088,14 +1310,9 @@ function buildExpectStubs(returnType, scenarioLabel, snapshotForComplexTypes = f
1088
1310
  stubs.push(` ${prop.name}: ${tsTypeToJestMatcher(prop.tsType)},`);
1089
1311
  }
1090
1312
  stubs.push("});");
1091
- stubs.push("// TODO: assert exact values, e.g. expect(result.id).toBe(expectedId)");
1092
1313
  }
1093
1314
  else {
1094
- stubs.push("expect(result).not.toBeNull();");
1095
- if (snapshotForComplexTypes && isComplexReturnType(unwrapped)) {
1096
- stubs.push("expect(result).toMatchSnapshot(); // Update snapshot after first run");
1097
- }
1098
- stubs.push("// TODO: add domain assertion — e.g. expect(result).toEqual(expected) or toHaveProperty('key', value)");
1315
+ stubs.push(`expect(result).toEqual(${expectedValue});`);
1099
1316
  }
1100
1317
  }
1101
1318
  return stubs;
@@ -1245,6 +1462,9 @@ function computeRelativeImport(targetFilePath, fromFilePath) {
1245
1462
  if (!rel.startsWith("."))
1246
1463
  rel = "./" + rel;
1247
1464
  const basename = path.basename(targetFilePath, ".ts");
1465
+ // Avoid double-slash when same directory (rel === "./")
1466
+ if (rel === ".")
1467
+ return `./${basename}`;
1248
1468
  return `${rel}/${basename}`;
1249
1469
  }
1250
1470
  // ─── Utility helpers ─────────────────────────────────────────────────────────
@@ -1271,4 +1491,137 @@ function safeName(name) {
1271
1491
  function capitalize(str) {
1272
1492
  return str.charAt(0).toUpperCase() + str.slice(1);
1273
1493
  }
1494
+ // ─── Constructor dependency resolution ───────────────────────────────────────
1495
+ /** Names to skip when resolving class methods — lifecycle hooks, internals, built-in prototypes */
1496
+ const SKIP_METHOD_NAMES = new Set([
1497
+ // Object prototype
1498
+ "constructor", "toString", "valueOf", "hasOwnProperty",
1499
+ "isPrototypeOf", "propertyIsEnumerable", "toLocaleString",
1500
+ // NestJS lifecycle hooks
1501
+ "onModuleInit", "onModuleDestroy", "onApplicationBootstrap",
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",
1522
+ ]);
1523
+ /**
1524
+ * For constructor-injected dependencies, resolve their actual public method
1525
+ * signatures by reading the dependency class via ts-morph. This fills in
1526
+ * `depMethodsByType` for params whose methods weren't found through the
1527
+ * body-text dependency scan (which misses `this.<param>.method()` patterns).
1528
+ */
1529
+ function resolveCtorDepMethods(ctorParams, depMethodsByType, sourceFilePath) {
1530
+ // Lazily create a ts-morph project only if needed
1531
+ let project = null;
1532
+ for (const param of ctorParams) {
1533
+ // Extract short type name from potentially fully-qualified type
1534
+ const shortType = param.type
1535
+ .replace(/^import\([^)]*\)\./, "")
1536
+ .replace(/<.*>$/, "")
1537
+ .trim();
1538
+ // Skip if we already have methods for this type (from dependency scan)
1539
+ if (depMethodsByType.has(shortType) && depMethodsByType.get(shortType).size > 0)
1540
+ continue;
1541
+ if (depMethodsByType.has(param.type) && depMethodsByType.get(param.type).size > 0)
1542
+ continue;
1543
+ // Skip primitives, built-in types, and ORM/logger patterns (handled elsewhere)
1544
+ const lower = shortType.toLowerCase();
1545
+ if (/^(string|number|boolean|void|any|never|unknown|undefined|null|object)$/.test(lower))
1546
+ continue;
1547
+ if (/nodepgdatabase|pgtransaction|drizzle|logger|configservice|eventemitter/i.test(lower))
1548
+ continue;
1549
+ try {
1550
+ if (!project) {
1551
+ const tsConfig = findTsConfig(sourceFilePath);
1552
+ project = new ts_morph_1.Project({ tsConfigFilePath: tsConfig });
1553
+ }
1554
+ // Find the source file of the dependency by resolving the import.
1555
+ // Normalize path separators — ts-morph uses forward slashes internally.
1556
+ const normalizedPath = sourceFilePath.replace(/\\/g, "/");
1557
+ let sourceFile = project.getSourceFile(normalizedPath);
1558
+ if (!sourceFile) {
1559
+ sourceFile = project.getSourceFile(sourceFilePath);
1560
+ }
1561
+ if (!sourceFile) {
1562
+ // If still not found, try adding it explicitly
1563
+ try {
1564
+ sourceFile = project.addSourceFileAtPath(sourceFilePath);
1565
+ }
1566
+ catch { /* ignore */ }
1567
+ }
1568
+ if (!sourceFile)
1569
+ continue;
1570
+ // Look for the import declaration that brings in this type
1571
+ const ctorImportDecl = sourceFile.getImportDeclarations().find(decl => {
1572
+ const namedImps = decl.getNamedImports();
1573
+ return namedImps.some(ni => ni.getName() === shortType);
1574
+ });
1575
+ if (!ctorImportDecl)
1576
+ continue;
1577
+ const ctorNamedImport = ctorImportDecl.getNamedImports().find(ni => ni.getName() === shortType);
1578
+ if (!ctorNamedImport)
1579
+ continue;
1580
+ let type = ctorNamedImport.getNameNode().getType();
1581
+ // If the type is `typeof ClassName` (the constructor), get the instance type
1582
+ // by looking at the construct signature's return type.
1583
+ const constructSigs = type.getConstructSignatures();
1584
+ if (constructSigs.length > 0) {
1585
+ type = constructSigs[0].getReturnType();
1586
+ }
1587
+ const props = type.getProperties();
1588
+ const methodSet = new Set();
1589
+ for (const prop of props) {
1590
+ const methodName = prop.getName();
1591
+ if (methodName.startsWith("_") || methodName.startsWith("#"))
1592
+ continue;
1593
+ if (SKIP_METHOD_NAMES.has(methodName))
1594
+ continue;
1595
+ const propType = prop.getTypeAtLocation(ctorNamedImport);
1596
+ const callSigs = propType.getCallSignatures();
1597
+ if (callSigs.length === 0)
1598
+ continue;
1599
+ const sig = callSigs[0];
1600
+ const returnTypeText = sig.getReturnType().getText();
1601
+ const isAsync = /^Promise</i.test(returnTypeText);
1602
+ methodSet.add({ name: methodName, isAsync });
1603
+ }
1604
+ if (methodSet.size > 0) {
1605
+ depMethodsByType.set(shortType, methodSet);
1606
+ }
1607
+ }
1608
+ catch {
1609
+ // Silently continue — AST resolution can fail for external deps
1610
+ }
1611
+ }
1612
+ }
1613
+ /**
1614
+ * Walk up from sourceFilePath to find the nearest tsconfig.json.
1615
+ */
1616
+ function findTsConfig(sourceFilePath) {
1617
+ let dir = path.dirname(sourceFilePath);
1618
+ while (dir !== path.dirname(dir)) {
1619
+ const candidate = path.join(dir, "tsconfig.json");
1620
+ if (fs.existsSync(candidate))
1621
+ return candidate;
1622
+ dir = path.dirname(dir);
1623
+ }
1624
+ // Fallback — let ts-morph use its own defaults
1625
+ return path.join(path.dirname(sourceFilePath), "tsconfig.json");
1626
+ }
1274
1627
  //# sourceMappingURL=generator.js.map