@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/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: ${className};`);
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("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")) {
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 with chainable query builder
549
- const chain = `${fn}.mockReturnThis()`;
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 ${className}(${args});`);
654
+ lines.push(` instance = new ${testClassName}(${args});`);
590
655
  }
591
656
  else {
592
- lines.push(` instance = new ${className}();`);
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
- // T-3: TODO for module-level singleton/cache reset
601
- 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
+ }
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 constName = mockResult?.constantNames.get(fn.functionName);
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 constName = mockResult?.constantNames.get(fn.functionName);
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
- // Improvement 3: add a commented dep-call verification tip for non-dep tests
970
+ // Add dependency-call verification for happy-path (valid input) tests
902
971
  if (!isDependencyTest && scenario.dependencies.length > 0) {
903
- const firstDep = scenario.dependencies[0];
904
- // Derive a mock variable name from the module path (e.g. "dashboard.service" → "mockDashboardService")
905
- const lastSegment = firstDep.modulePath.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "dependency";
906
- const mockVarName = "mock" + lastSegment
907
- .split(/[-_.]/)
908
- .map(s => s.charAt(0).toUpperCase() + s.slice(1))
909
- .join("");
910
- // Pick the first known method name, or fall back to a generic placeholder
911
- const methodName = firstDep.importedFunctionMeta[0]?.name
912
- ?? firstDep.importedNames.find(n => n !== "default")
913
- ?? "method";
914
- lines.push("");
915
- lines.push(` // Tip: verify a specific dependency was invoked:`);
916
- lines.push(` // expect(${mockVarName}.${methodName}).toHaveBeenCalledTimes(1);`);
917
- 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();");
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
- lines.push(` expect(() => ${caller}(${argsStr})).toThrow(Error);`);
956
- 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});`);
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 (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) {
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
- lines.push(` await expect(${caller}(${argsStr})).rejects.toThrow();`);
993
- 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
+ }
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
- 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);
1052
1260
  if (unwrapped === "number") {
1053
- stubs.push("expect(typeof result).toBe('number');");
1054
- if (scenarioLabel.includes("negative"))
1055
- stubs.push("// expect(result).toBeLessThan(0);");
1056
- if (scenarioLabel.includes("zero"))
1057
- stubs.push("// expect(result).toBe(0);");
1058
- if (scenarioLabel.includes("boundary"))
1059
- stubs.push("expect(Number.isFinite(result)).toBe(true);");
1060
- 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
+ }
1061
1270
  }
1062
1271
  else if (unwrapped === "string") {
1063
- stubs.push("expect(typeof result).toBe('string');");
1064
- if (scenarioLabel.includes("empty"))
1065
- stubs.push("// expect(result).toBe('');");
1066
- if (scenarioLabel.includes("valid"))
1067
- stubs.push("expect(result.length).toBeGreaterThan(0);");
1068
- 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
+ }
1069
1278
  }
1070
1279
  else if (unwrapped === "boolean") {
1071
- stubs.push("expect(typeof result).toBe('boolean');");
1072
- if (scenarioLabel.includes("truthy"))
1073
- stubs.push("// expect(result).toBe(true);");
1074
- if (scenarioLabel.includes("falsy"))
1075
- stubs.push("// expect(result).toBe(false);");
1076
- stubs.push("// TODO: add domain assertion — e.g. expect(result).toBe(true)");
1077
- }
1078
- else if (unwrapped === "null") {
1079
- stubs.length = 0;
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("// expect(result).toHaveLength(0);");
1090
- 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});`);
1091
1296
  }
1092
1297
  else if (/ApiResponseDto/i.test(returnType)) {
1093
1298
  // Controller methods wrapped in ApiResponseDto<T>
1094
- stubs.push("expect(result).toHaveProperty('success');");
1095
- stubs.push("expect(result).toHaveProperty('data');");
1096
- 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("}));");
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("expect(result).not.toBeNull();");
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