@atlashub/smartstack-mcp 1.4.1 → 1.5.1
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/index.js +3051 -158
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
- package/templates/tests/controller.test.cs.hbs +413 -0
- package/templates/tests/entity.test.cs.hbs +239 -0
- package/templates/tests/repository.test.cs.hbs +441 -0
- package/templates/tests/security.test.cs.hbs +442 -0
- package/templates/tests/service.test.cs.hbs +390 -0
- package/templates/tests/validator.test.cs.hbs +428 -0
package/dist/index.js
CHANGED
|
@@ -78,14 +78,14 @@ if (envLevel && ["debug", "info", "warn", "error"].includes(envLevel)) {
|
|
|
78
78
|
import path2 from "path";
|
|
79
79
|
|
|
80
80
|
// src/utils/fs.ts
|
|
81
|
-
import
|
|
81
|
+
import { stat, mkdir, readFile, writeFile, cp, rm } from "fs/promises";
|
|
82
82
|
import path from "path";
|
|
83
83
|
import { glob } from "glob";
|
|
84
84
|
var FileSystemError = class extends Error {
|
|
85
|
-
constructor(message, operation,
|
|
85
|
+
constructor(message, operation, path18, cause) {
|
|
86
86
|
super(message);
|
|
87
87
|
this.operation = operation;
|
|
88
|
-
this.path =
|
|
88
|
+
this.path = path18;
|
|
89
89
|
this.cause = cause;
|
|
90
90
|
this.name = "FileSystemError";
|
|
91
91
|
}
|
|
@@ -103,26 +103,26 @@ function validatePathSecurity(targetPath, baseDir) {
|
|
|
103
103
|
}
|
|
104
104
|
async function fileExists(filePath) {
|
|
105
105
|
try {
|
|
106
|
-
const
|
|
107
|
-
return
|
|
106
|
+
const s = await stat(filePath);
|
|
107
|
+
return s.isFile();
|
|
108
108
|
} catch {
|
|
109
109
|
return false;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
async function directoryExists(dirPath) {
|
|
113
113
|
try {
|
|
114
|
-
const
|
|
115
|
-
return
|
|
114
|
+
const s = await stat(dirPath);
|
|
115
|
+
return s.isDirectory();
|
|
116
116
|
} catch {
|
|
117
117
|
return false;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
async function ensureDirectory(dirPath) {
|
|
121
|
-
await
|
|
121
|
+
await mkdir(dirPath, { recursive: true });
|
|
122
122
|
}
|
|
123
123
|
async function readJson(filePath) {
|
|
124
124
|
try {
|
|
125
|
-
const content = await
|
|
125
|
+
const content = await readFile(filePath, "utf-8");
|
|
126
126
|
try {
|
|
127
127
|
return JSON.parse(content);
|
|
128
128
|
} catch (parseError) {
|
|
@@ -148,7 +148,7 @@ async function readJson(filePath) {
|
|
|
148
148
|
}
|
|
149
149
|
async function readText(filePath) {
|
|
150
150
|
try {
|
|
151
|
-
return await
|
|
151
|
+
return await readFile(filePath, "utf-8");
|
|
152
152
|
} catch (error) {
|
|
153
153
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
154
154
|
throw new FileSystemError(
|
|
@@ -161,8 +161,8 @@ async function readText(filePath) {
|
|
|
161
161
|
}
|
|
162
162
|
async function writeText(filePath, content) {
|
|
163
163
|
try {
|
|
164
|
-
await
|
|
165
|
-
await
|
|
164
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
165
|
+
await writeFile(filePath, content, "utf-8");
|
|
166
166
|
} catch (error) {
|
|
167
167
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
168
168
|
throw new FileSystemError(
|
|
@@ -456,6 +456,41 @@ var ApiDocsInputSchema = z.object({
|
|
|
456
456
|
format: z.enum(["markdown", "json", "openapi"]).default("markdown").describe("Output format"),
|
|
457
457
|
controller: z.string().optional().describe("Filter by controller name")
|
|
458
458
|
});
|
|
459
|
+
var TestTypeSchema = z.enum(["unit", "integration", "security", "e2e"]);
|
|
460
|
+
var TestTargetSchema = z.enum(["entity", "service", "controller", "validator", "repository", "all"]);
|
|
461
|
+
var ScaffoldTestsInputSchema = z.object({
|
|
462
|
+
target: TestTargetSchema.describe("Type of component to test"),
|
|
463
|
+
name: z.string().min(1).describe('Component name (PascalCase, e.g., "User", "Order")'),
|
|
464
|
+
testTypes: z.array(TestTypeSchema).default(["unit"]).describe("Types of tests to generate"),
|
|
465
|
+
options: z.object({
|
|
466
|
+
includeEdgeCases: z.boolean().default(true).describe("Include edge case tests"),
|
|
467
|
+
includeTenantIsolation: z.boolean().default(true).describe("Include tenant isolation tests"),
|
|
468
|
+
includeSoftDelete: z.boolean().default(true).describe("Include soft delete tests"),
|
|
469
|
+
includeAudit: z.boolean().default(true).describe("Include audit trail tests"),
|
|
470
|
+
includeValidation: z.boolean().default(true).describe("Include validation tests"),
|
|
471
|
+
includeAuthorization: z.boolean().default(false).describe("Include authorization tests"),
|
|
472
|
+
includePerformance: z.boolean().default(false).describe("Include performance tests"),
|
|
473
|
+
entityProperties: z.array(EntityPropertySchema).optional().describe("Entity properties for test generation"),
|
|
474
|
+
isSystemEntity: z.boolean().default(false).describe("If true, entity has no TenantId"),
|
|
475
|
+
dryRun: z.boolean().default(false).describe("Preview without writing files")
|
|
476
|
+
}).optional()
|
|
477
|
+
});
|
|
478
|
+
var AnalyzeTestCoverageInputSchema = z.object({
|
|
479
|
+
path: z.string().optional().describe("Project path to analyze"),
|
|
480
|
+
scope: z.enum(["entity", "service", "controller", "all"]).default("all").describe("Scope of analysis"),
|
|
481
|
+
outputFormat: z.enum(["summary", "detailed", "json"]).default("summary").describe("Output format"),
|
|
482
|
+
includeRecommendations: z.boolean().default(true).describe("Include recommendations for missing tests")
|
|
483
|
+
});
|
|
484
|
+
var ValidateTestConventionsInputSchema = z.object({
|
|
485
|
+
path: z.string().optional().describe("Project path to validate"),
|
|
486
|
+
checks: z.array(z.enum(["naming", "structure", "patterns", "assertions", "mocking", "all"])).default(["all"]).describe("Types of convention checks to perform"),
|
|
487
|
+
autoFix: z.boolean().default(false).describe("Automatically fix minor issues")
|
|
488
|
+
});
|
|
489
|
+
var SuggestTestScenariosInputSchema = z.object({
|
|
490
|
+
target: z.enum(["entity", "service", "controller", "file"]).describe("Type of target to analyze"),
|
|
491
|
+
name: z.string().min(1).describe("Component name or file path"),
|
|
492
|
+
depth: z.enum(["basic", "comprehensive", "security-focused"]).default("comprehensive").describe("Depth of analysis")
|
|
493
|
+
});
|
|
459
494
|
|
|
460
495
|
// src/lib/detector.ts
|
|
461
496
|
import path4 from "path";
|
|
@@ -2398,7 +2433,7 @@ export function use{{name}}(options: Use{{name}}Options = {}) {
|
|
|
2398
2433
|
}
|
|
2399
2434
|
async function scaffoldTest(name, options, structure, config, result, dryRun = false) {
|
|
2400
2435
|
const isSystemEntity = options?.isSystemEntity || false;
|
|
2401
|
-
const
|
|
2436
|
+
const serviceTestTemplate2 = `using System;
|
|
2402
2437
|
using System.Threading;
|
|
2403
2438
|
using System.Threading.Tasks;
|
|
2404
2439
|
using Microsoft.Extensions.Logging;
|
|
@@ -2505,7 +2540,7 @@ public class {{name}}ServiceTests
|
|
|
2505
2540
|
name,
|
|
2506
2541
|
isSystemEntity
|
|
2507
2542
|
};
|
|
2508
|
-
const testContent = Handlebars.compile(
|
|
2543
|
+
const testContent = Handlebars.compile(serviceTestTemplate2)(context);
|
|
2509
2544
|
const testsPath = structure.application ? path7.join(path7.dirname(structure.application), `${path7.basename(structure.application)}.Tests`, "Services") : path7.join(config.smartstack.projectPath, "Application.Tests", "Services");
|
|
2510
2545
|
const testFilePath = path7.join(testsPath, `${name}ServiceTests.cs`);
|
|
2511
2546
|
if (!dryRun) {
|
|
@@ -3446,175 +3481,3015 @@ function compareVersions2(a, b) {
|
|
|
3446
3481
|
return 0;
|
|
3447
3482
|
}
|
|
3448
3483
|
|
|
3449
|
-
// src/
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3484
|
+
// src/tools/scaffold-tests.ts
|
|
3485
|
+
import Handlebars2 from "handlebars";
|
|
3486
|
+
import path10 from "path";
|
|
3487
|
+
var scaffoldTestsTool = {
|
|
3488
|
+
name: "scaffold_tests",
|
|
3489
|
+
description: "Generate unit, integration, and security tests for SmartStack entities, services, controllers, validators, and repositories. Ensures non-regression and maximum security coverage.",
|
|
3490
|
+
inputSchema: {
|
|
3491
|
+
type: "object",
|
|
3492
|
+
properties: {
|
|
3493
|
+
target: {
|
|
3494
|
+
type: "string",
|
|
3495
|
+
enum: ["entity", "service", "controller", "validator", "repository", "all"],
|
|
3496
|
+
description: "Type of component to test"
|
|
3497
|
+
},
|
|
3498
|
+
name: {
|
|
3499
|
+
type: "string",
|
|
3500
|
+
description: 'Component name (PascalCase, e.g., "User", "Order")'
|
|
3501
|
+
},
|
|
3502
|
+
testTypes: {
|
|
3503
|
+
type: "array",
|
|
3504
|
+
items: {
|
|
3505
|
+
type: "string",
|
|
3506
|
+
enum: ["unit", "integration", "security", "e2e"]
|
|
3507
|
+
},
|
|
3508
|
+
default: ["unit"],
|
|
3509
|
+
description: "Types of tests to generate"
|
|
3510
|
+
},
|
|
3511
|
+
options: {
|
|
3512
|
+
type: "object",
|
|
3513
|
+
properties: {
|
|
3514
|
+
includeEdgeCases: {
|
|
3515
|
+
type: "boolean",
|
|
3516
|
+
default: true,
|
|
3517
|
+
description: "Include edge case tests"
|
|
3518
|
+
},
|
|
3519
|
+
includeTenantIsolation: {
|
|
3520
|
+
type: "boolean",
|
|
3521
|
+
default: true,
|
|
3522
|
+
description: "Include tenant isolation tests"
|
|
3523
|
+
},
|
|
3524
|
+
includeSoftDelete: {
|
|
3525
|
+
type: "boolean",
|
|
3526
|
+
default: true,
|
|
3527
|
+
description: "Include soft delete tests"
|
|
3528
|
+
},
|
|
3529
|
+
includeAudit: {
|
|
3530
|
+
type: "boolean",
|
|
3531
|
+
default: true,
|
|
3532
|
+
description: "Include audit trail tests"
|
|
3533
|
+
},
|
|
3534
|
+
includeValidation: {
|
|
3535
|
+
type: "boolean",
|
|
3536
|
+
default: true,
|
|
3537
|
+
description: "Include validation tests"
|
|
3538
|
+
},
|
|
3539
|
+
includeAuthorization: {
|
|
3540
|
+
type: "boolean",
|
|
3541
|
+
default: false,
|
|
3542
|
+
description: "Include authorization tests"
|
|
3543
|
+
},
|
|
3544
|
+
includePerformance: {
|
|
3545
|
+
type: "boolean",
|
|
3546
|
+
default: false,
|
|
3547
|
+
description: "Include performance tests"
|
|
3548
|
+
},
|
|
3549
|
+
isSystemEntity: {
|
|
3550
|
+
type: "boolean",
|
|
3551
|
+
default: false,
|
|
3552
|
+
description: "If true, entity has no TenantId"
|
|
3553
|
+
},
|
|
3554
|
+
dryRun: {
|
|
3555
|
+
type: "boolean",
|
|
3556
|
+
default: false,
|
|
3557
|
+
description: "Preview without writing files"
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
},
|
|
3562
|
+
required: ["target", "name"]
|
|
3563
|
+
}
|
|
3455
3564
|
};
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
return
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3565
|
+
Handlebars2.registerHelper("pascalCase", (str) => {
|
|
3566
|
+
if (!str) return "";
|
|
3567
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
3568
|
+
});
|
|
3569
|
+
Handlebars2.registerHelper("camelCase", (str) => {
|
|
3570
|
+
if (!str) return "";
|
|
3571
|
+
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
3572
|
+
});
|
|
3573
|
+
Handlebars2.registerHelper("unless", function(conditional, options) {
|
|
3574
|
+
if (!conditional) {
|
|
3575
|
+
return options.fn(this);
|
|
3576
|
+
}
|
|
3577
|
+
return options.inverse(this);
|
|
3578
|
+
});
|
|
3579
|
+
var entityTestTemplate = `using System;
|
|
3580
|
+
using FluentAssertions;
|
|
3581
|
+
using Xunit;
|
|
3582
|
+
using {{domainNamespace}};
|
|
3464
3583
|
|
|
3465
|
-
|
|
3584
|
+
namespace {{testNamespace}}.Unit.Domain;
|
|
3466
3585
|
|
|
3467
|
-
|
|
3586
|
+
/// <summary>
|
|
3587
|
+
/// Unit tests for {{name}} entity
|
|
3588
|
+
/// Tests: Factory methods, Soft delete, Audit trail, Validation
|
|
3589
|
+
/// </summary>
|
|
3590
|
+
public class {{name}}Tests
|
|
3591
|
+
{
|
|
3592
|
+
private const string ValidCode = "test_code";
|
|
3593
|
+
{{#unless isSystemEntity}}
|
|
3594
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
3595
|
+
{{/unless}}
|
|
3468
3596
|
|
|
3469
|
-
|
|
3597
|
+
#region Factory Method Tests
|
|
3470
3598
|
|
|
3471
|
-
|
|
3599
|
+
[Fact]
|
|
3600
|
+
public void Create_WhenValidData_ShouldCreateEntity()
|
|
3601
|
+
{
|
|
3602
|
+
// Arrange
|
|
3603
|
+
var code = ValidCode;
|
|
3604
|
+
{{#unless isSystemEntity}}
|
|
3605
|
+
var tenantId = _tenantId;
|
|
3606
|
+
{{/unless}}
|
|
3472
3607
|
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
| \`${schemas.platform}\` | SmartStack platform | All native SmartStack tables |
|
|
3476
|
-
| \`${schemas.extensions}\` | Client extensions | Custom tables added by clients |
|
|
3608
|
+
// Act
|
|
3609
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}tenantId, {{/unless}}code);
|
|
3477
3610
|
|
|
3478
|
-
|
|
3611
|
+
// Assert
|
|
3612
|
+
entity.Should().NotBeNull();
|
|
3613
|
+
entity.Id.Should().NotBeEmpty();
|
|
3614
|
+
entity.Code.Should().Be(code.ToLowerInvariant());
|
|
3615
|
+
{{#unless isSystemEntity}}
|
|
3616
|
+
entity.TenantId.Should().Be(tenantId);
|
|
3617
|
+
{{/unless}}
|
|
3618
|
+
entity.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
3619
|
+
entity.IsDeleted.Should().BeFalse();
|
|
3620
|
+
}
|
|
3479
3621
|
|
|
3480
|
-
|
|
3622
|
+
{{#unless isSystemEntity}}
|
|
3623
|
+
[Fact]
|
|
3624
|
+
public void Create_WhenEmptyTenantId_ShouldThrow()
|
|
3625
|
+
{
|
|
3626
|
+
// Arrange
|
|
3627
|
+
var tenantId = Guid.Empty;
|
|
3628
|
+
var code = ValidCode;
|
|
3481
3629
|
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
| \`auth_\` | Authorization | auth_Users, auth_Roles, auth_Permissions |
|
|
3485
|
-
| \`nav_\` | Navigation | nav_Contexts, nav_Applications, nav_Modules |
|
|
3486
|
-
| \`usr_\` | User profiles | usr_Profiles, usr_Preferences |
|
|
3487
|
-
| \`ai_\` | AI features | ai_Providers, ai_Models, ai_Prompts |
|
|
3488
|
-
| \`cfg_\` | Configuration | cfg_Settings |
|
|
3489
|
-
| \`wkf_\` | Workflows | wkf_EmailTemplates, wkf_Workflows |
|
|
3490
|
-
| \`support_\` | Support | support_Tickets, support_Comments |
|
|
3491
|
-
| \`entra_\` | Entra sync | entra_Groups, entra_SyncState |
|
|
3492
|
-
| \`ref_\` | References | ref_Companies, ref_Departments |
|
|
3493
|
-
| \`loc_\` | Localization | loc_Languages, loc_Translations |
|
|
3494
|
-
| \`lic_\` | Licensing | lic_Licenses |
|
|
3495
|
-
| \`tenant_\` | Multi-Tenancy | tenant_Tenants, tenant_TenantUsers, tenant_TenantUserRoles |
|
|
3630
|
+
// Act
|
|
3631
|
+
var act = () => {{name}}.Create(tenantId, code);
|
|
3496
3632
|
|
|
3497
|
-
|
|
3633
|
+
// Assert
|
|
3634
|
+
act.Should().Throw<ArgumentException>()
|
|
3635
|
+
.WithParameterName("tenantId");
|
|
3636
|
+
}
|
|
3637
|
+
{{/unless}}
|
|
3498
3638
|
|
|
3499
|
-
|
|
3639
|
+
[Theory]
|
|
3640
|
+
[InlineData("")]
|
|
3641
|
+
[InlineData(" ")]
|
|
3642
|
+
public void Create_WhenEmptyCode_ShouldHandleGracefully(string code)
|
|
3643
|
+
{
|
|
3644
|
+
// Arrange
|
|
3645
|
+
{{#unless isSystemEntity}}
|
|
3646
|
+
var tenantId = _tenantId;
|
|
3647
|
+
{{/unless}}
|
|
3500
3648
|
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
| SmartStack (system) | \`${codePrefixes.core}\` | Protected, delivered with SmartStack | \`${codePrefixes.core}administration\` |
|
|
3504
|
-
| Client (extension) | \`${codePrefixes.extension}\` | Custom, added by clients | \`${codePrefixes.extension}it\` |
|
|
3649
|
+
// Act
|
|
3650
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}tenantId, {{/unless}}code);
|
|
3505
3651
|
|
|
3506
|
-
|
|
3652
|
+
// Assert
|
|
3653
|
+
entity.Should().NotBeNull();
|
|
3654
|
+
entity.Code.Should().BeEmpty();
|
|
3655
|
+
}
|
|
3507
3656
|
|
|
3508
|
-
|
|
3509
|
-
Context \u2192 Application \u2192 Module \u2192 Section \u2192 Resource
|
|
3510
|
-
\`\`\`
|
|
3657
|
+
#endregion
|
|
3511
3658
|
|
|
3512
|
-
|
|
3659
|
+
{{#if includeSoftDelete}}
|
|
3660
|
+
#region Soft Delete Tests
|
|
3513
3661
|
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
| Resource | \`${codePrefixes.core}user_list\` | \`${codePrefixes.extension}stock_view\` |
|
|
3662
|
+
[Fact]
|
|
3663
|
+
public void SoftDelete_WhenCalled_ShouldSetIsDeletedTrue()
|
|
3664
|
+
{
|
|
3665
|
+
// Arrange
|
|
3666
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3667
|
+
var deletedBy = "test_user";
|
|
3521
3668
|
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
2. \`${codePrefixes.extension}*\` codes are **free** - clients can create custom navigation
|
|
3525
|
-
3. SmartStack updates will never overwrite \`${codePrefixes.extension}*\` data
|
|
3526
|
-
4. Codes must be unique within their level (e.g., no two Contexts with same code)
|
|
3669
|
+
// Act
|
|
3670
|
+
entity.SoftDelete(deletedBy);
|
|
3527
3671
|
|
|
3528
|
-
|
|
3672
|
+
// Assert
|
|
3673
|
+
entity.IsDeleted.Should().BeTrue();
|
|
3674
|
+
entity.DeletedAt.Should().NotBeNull();
|
|
3675
|
+
entity.DeletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
3676
|
+
entity.DeletedBy.Should().Be(deletedBy);
|
|
3677
|
+
}
|
|
3529
3678
|
|
|
3530
|
-
|
|
3531
|
-
public
|
|
3532
|
-
{
|
|
3533
|
-
public void Configure(EntityTypeBuilder<MyEntity> builder)
|
|
3679
|
+
[Fact]
|
|
3680
|
+
public void SoftDelete_WhenAlreadyDeleted_ShouldNotChangeDeletedAt()
|
|
3534
3681
|
{
|
|
3535
|
-
//
|
|
3536
|
-
|
|
3682
|
+
// Arrange
|
|
3683
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3684
|
+
entity.SoftDelete("first_deleter");
|
|
3685
|
+
var originalDeletedAt = entity.DeletedAt;
|
|
3537
3686
|
|
|
3538
|
-
//
|
|
3539
|
-
|
|
3687
|
+
// Act
|
|
3688
|
+
entity.SoftDelete("second_deleter");
|
|
3540
3689
|
|
|
3541
|
-
//
|
|
3542
|
-
|
|
3690
|
+
// Assert
|
|
3691
|
+
entity.DeletedAt.Should().Be(originalDeletedAt);
|
|
3543
3692
|
}
|
|
3544
|
-
}
|
|
3545
|
-
\`\`\`
|
|
3546
3693
|
|
|
3547
|
-
|
|
3694
|
+
[Fact]
|
|
3695
|
+
public void Restore_WhenSoftDeleted_ShouldClearDeletedFields()
|
|
3696
|
+
{
|
|
3697
|
+
// Arrange
|
|
3698
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3699
|
+
entity.SoftDelete("deleter");
|
|
3700
|
+
var restoredBy = "restorer";
|
|
3548
3701
|
|
|
3549
|
-
|
|
3702
|
+
// Act
|
|
3703
|
+
entity.Restore(restoredBy);
|
|
3550
3704
|
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3705
|
+
// Assert
|
|
3706
|
+
entity.IsDeleted.Should().BeFalse();
|
|
3707
|
+
entity.DeletedAt.Should().BeNull();
|
|
3708
|
+
entity.DeletedBy.Should().BeNull();
|
|
3709
|
+
entity.UpdatedBy.Should().Be(restoredBy);
|
|
3710
|
+
}
|
|
3557
3711
|
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
}
|
|
3712
|
+
#endregion
|
|
3713
|
+
{{/if}}
|
|
3561
3714
|
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
builder.HasOne(e => e.User)
|
|
3565
|
-
.WithOne()
|
|
3566
|
-
.HasForeignKey<UserExtension>(e => e.UserId);
|
|
3567
|
-
\`\`\`
|
|
3715
|
+
{{#if includeAudit}}
|
|
3716
|
+
#region Audit Trail Tests
|
|
3568
3717
|
|
|
3569
|
-
|
|
3718
|
+
[Fact]
|
|
3719
|
+
public void Create_ShouldSetCreatedAtToUtcNow()
|
|
3720
|
+
{
|
|
3721
|
+
// Arrange & Act
|
|
3722
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3570
3723
|
|
|
3571
|
-
|
|
3724
|
+
// Assert
|
|
3725
|
+
entity.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
3726
|
+
entity.CreatedAt.Kind.Should().Be(DateTimeKind.Utc);
|
|
3727
|
+
}
|
|
3572
3728
|
|
|
3573
|
-
|
|
3729
|
+
[Fact]
|
|
3730
|
+
public void Create_WhenCreatedByProvided_ShouldSetCreatedBy()
|
|
3731
|
+
{
|
|
3732
|
+
// Arrange
|
|
3733
|
+
var createdBy = "test_creator";
|
|
3574
3734
|
|
|
3575
|
-
|
|
3735
|
+
// Act
|
|
3736
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode, createdBy);
|
|
3576
3737
|
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3738
|
+
// Assert
|
|
3739
|
+
entity.CreatedBy.Should().Be(createdBy);
|
|
3740
|
+
}
|
|
3580
3741
|
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3742
|
+
[Fact]
|
|
3743
|
+
public void Update_WhenCalled_ShouldUpdateAuditFields()
|
|
3744
|
+
{
|
|
3745
|
+
// Arrange
|
|
3746
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3747
|
+
var originalCreatedAt = entity.CreatedAt;
|
|
3748
|
+
var updatedBy = "updater";
|
|
3587
3749
|
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
- \`core_v1.0.0_002_CreateAuthUsers.cs\`
|
|
3591
|
-
- \`core_v1.2.0_001_AddUserProfiles.cs\`
|
|
3592
|
-
- \`extensions_v1.0.0_001_AddClientFeatures.cs\`
|
|
3750
|
+
// Act
|
|
3751
|
+
entity.Update(updatedBy);
|
|
3593
3752
|
|
|
3594
|
-
|
|
3753
|
+
// Assert
|
|
3754
|
+
entity.UpdatedAt.Should().NotBeNull();
|
|
3755
|
+
entity.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
3756
|
+
entity.UpdatedBy.Should().Be(updatedBy);
|
|
3757
|
+
entity.CreatedAt.Should().Be(originalCreatedAt);
|
|
3758
|
+
}
|
|
3595
3759
|
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
dotnet ef migrations add core_v1.0.0_001_InitialSchema
|
|
3760
|
+
#endregion
|
|
3761
|
+
{{/if}}
|
|
3599
3762
|
|
|
3600
|
-
#
|
|
3601
|
-
|
|
3602
|
-
\`\`\`
|
|
3763
|
+
{{#if includeEdgeCases}}
|
|
3764
|
+
#region Edge Cases
|
|
3603
3765
|
|
|
3604
|
-
|
|
3766
|
+
[Fact]
|
|
3767
|
+
public void Create_WithSpecialCharactersInCode_ShouldNormalize()
|
|
3768
|
+
{
|
|
3769
|
+
// Arrange
|
|
3770
|
+
var code = "Test-Code_123";
|
|
3605
3771
|
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
3. **Sequence numbers** - Use NNN (001, 002, etc.) for migrations in the same version
|
|
3609
|
-
4. **Context prefix** - Use \`core_\` for platform tables, \`extensions_\` for client extensions
|
|
3610
|
-
5. **Descriptive names** - Use clear PascalCase descriptions (CreateAuthUsers, AddUserProfiles, etc.)
|
|
3611
|
-
6. **Schema must be specified** - All tables must specify their schema in ToTable()
|
|
3772
|
+
// Act
|
|
3773
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}code);
|
|
3612
3774
|
|
|
3613
|
-
|
|
3775
|
+
// Assert
|
|
3776
|
+
entity.Code.Should().Be(code.ToLowerInvariant());
|
|
3777
|
+
}
|
|
3614
3778
|
|
|
3615
|
-
|
|
3779
|
+
[Fact]
|
|
3780
|
+
public void Create_WithMaxLengthCode_ShouldSucceed()
|
|
3781
|
+
{
|
|
3782
|
+
// Arrange
|
|
3783
|
+
var code = new string('a', 100);
|
|
3616
3784
|
|
|
3617
|
-
|
|
3785
|
+
// Act
|
|
3786
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}code);
|
|
3787
|
+
|
|
3788
|
+
// Assert
|
|
3789
|
+
entity.Code.Should().HaveLength(100);
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
#endregion
|
|
3793
|
+
{{/if}}
|
|
3794
|
+
}
|
|
3795
|
+
`;
|
|
3796
|
+
var serviceTestTemplate = `using System;
|
|
3797
|
+
using System.Collections.Generic;
|
|
3798
|
+
using System.Threading;
|
|
3799
|
+
using System.Threading.Tasks;
|
|
3800
|
+
using FluentAssertions;
|
|
3801
|
+
using Microsoft.Extensions.Logging;
|
|
3802
|
+
using Moq;
|
|
3803
|
+
using Xunit;
|
|
3804
|
+
using {{applicationNamespace}}.Services;
|
|
3805
|
+
using {{applicationNamespace}}.Repositories;
|
|
3806
|
+
using {{domainNamespace}};
|
|
3807
|
+
|
|
3808
|
+
namespace {{testNamespace}}.Unit.Services;
|
|
3809
|
+
|
|
3810
|
+
/// <summary>
|
|
3811
|
+
/// Unit tests for {{name}}Service
|
|
3812
|
+
/// Tests: CRUD operations, Business logic, Error handling, Tenant isolation
|
|
3813
|
+
/// </summary>
|
|
3814
|
+
public class {{name}}ServiceTests
|
|
3815
|
+
{
|
|
3816
|
+
private readonly Mock<I{{name}}Repository> _repositoryMock;
|
|
3817
|
+
private readonly Mock<ILogger<{{name}}Service>> _loggerMock;
|
|
3818
|
+
private readonly {{name}}Service _sut;
|
|
3819
|
+
{{#unless isSystemEntity}}
|
|
3820
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
3821
|
+
{{/unless}}
|
|
3822
|
+
|
|
3823
|
+
public {{name}}ServiceTests()
|
|
3824
|
+
{
|
|
3825
|
+
_repositoryMock = new Mock<I{{name}}Repository>();
|
|
3826
|
+
_loggerMock = new Mock<ILogger<{{name}}Service>>();
|
|
3827
|
+
_sut = new {{name}}Service(
|
|
3828
|
+
_repositoryMock.Object,
|
|
3829
|
+
_loggerMock.Object
|
|
3830
|
+
);
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
#region GetByIdAsync Tests
|
|
3834
|
+
|
|
3835
|
+
[Fact]
|
|
3836
|
+
public async Task GetByIdAsync_WhenEntityExists_ShouldReturnEntity()
|
|
3837
|
+
{
|
|
3838
|
+
// Arrange
|
|
3839
|
+
var id = Guid.NewGuid();
|
|
3840
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
3841
|
+
_repositoryMock
|
|
3842
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3843
|
+
.ReturnsAsync(entity);
|
|
3844
|
+
|
|
3845
|
+
// Act
|
|
3846
|
+
var result = await _sut.GetByIdAsync(id);
|
|
3847
|
+
|
|
3848
|
+
// Assert
|
|
3849
|
+
result.Should().NotBeNull();
|
|
3850
|
+
_repositoryMock.Verify(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()), Times.Once);
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
[Fact]
|
|
3854
|
+
public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull()
|
|
3855
|
+
{
|
|
3856
|
+
// Arrange
|
|
3857
|
+
var id = Guid.NewGuid();
|
|
3858
|
+
_repositoryMock
|
|
3859
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3860
|
+
.ReturnsAsync(({{name}}?)null);
|
|
3861
|
+
|
|
3862
|
+
// Act
|
|
3863
|
+
var result = await _sut.GetByIdAsync(id);
|
|
3864
|
+
|
|
3865
|
+
// Assert
|
|
3866
|
+
result.Should().BeNull();
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
[Fact]
|
|
3870
|
+
public async Task GetByIdAsync_WhenCancelled_ShouldThrowOperationCancelledException()
|
|
3871
|
+
{
|
|
3872
|
+
// Arrange
|
|
3873
|
+
var id = Guid.NewGuid();
|
|
3874
|
+
var cts = new CancellationTokenSource();
|
|
3875
|
+
cts.Cancel();
|
|
3876
|
+
|
|
3877
|
+
_repositoryMock
|
|
3878
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3879
|
+
.ThrowsAsync(new OperationCanceledException());
|
|
3880
|
+
|
|
3881
|
+
// Act
|
|
3882
|
+
var act = async () => await _sut.GetByIdAsync(id, cts.Token);
|
|
3883
|
+
|
|
3884
|
+
// Assert
|
|
3885
|
+
await act.Should().ThrowAsync<OperationCanceledException>();
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
#endregion
|
|
3889
|
+
|
|
3890
|
+
{{#unless isSystemEntity}}
|
|
3891
|
+
#region Tenant Isolation Tests
|
|
3892
|
+
|
|
3893
|
+
[Fact]
|
|
3894
|
+
public async Task GetAllAsync_ShouldFilterByTenant()
|
|
3895
|
+
{
|
|
3896
|
+
// Arrange
|
|
3897
|
+
var entities = new List<{{name}}>
|
|
3898
|
+
{
|
|
3899
|
+
{{name}}.Create(_tenantId, "entity1"),
|
|
3900
|
+
{{name}}.Create(_tenantId, "entity2"),
|
|
3901
|
+
};
|
|
3902
|
+
_repositoryMock
|
|
3903
|
+
.Setup(r => r.GetAllByTenantAsync(_tenantId, It.IsAny<CancellationToken>()))
|
|
3904
|
+
.ReturnsAsync(entities);
|
|
3905
|
+
|
|
3906
|
+
// Act
|
|
3907
|
+
var result = await _sut.GetAllAsync(_tenantId);
|
|
3908
|
+
|
|
3909
|
+
// Assert
|
|
3910
|
+
result.Should().HaveCount(2);
|
|
3911
|
+
result.Should().OnlyContain(e => e.TenantId == _tenantId);
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
[Fact]
|
|
3915
|
+
public async Task CreateAsync_ShouldSetTenantId()
|
|
3916
|
+
{
|
|
3917
|
+
// Arrange
|
|
3918
|
+
{{name}}? capturedEntity = null;
|
|
3919
|
+
_repositoryMock
|
|
3920
|
+
.Setup(r => r.AddAsync(It.IsAny<{{name}}>(), It.IsAny<CancellationToken>()))
|
|
3921
|
+
.Callback<{{name}}, CancellationToken>((e, _) => capturedEntity = e)
|
|
3922
|
+
.ReturnsAsync(({{name}} e, CancellationToken _) => e);
|
|
3923
|
+
|
|
3924
|
+
// Act
|
|
3925
|
+
await _sut.CreateAsync(_tenantId, "test_code");
|
|
3926
|
+
|
|
3927
|
+
// Assert
|
|
3928
|
+
capturedEntity.Should().NotBeNull();
|
|
3929
|
+
capturedEntity!.TenantId.Should().Be(_tenantId);
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
[Fact]
|
|
3933
|
+
public async Task GetByIdAsync_WhenEntityBelongsToDifferentTenant_ShouldReturnNull()
|
|
3934
|
+
{
|
|
3935
|
+
// Arrange
|
|
3936
|
+
var id = Guid.NewGuid();
|
|
3937
|
+
var otherTenantId = Guid.NewGuid();
|
|
3938
|
+
var entity = {{name}}.Create(otherTenantId, "test");
|
|
3939
|
+
|
|
3940
|
+
_repositoryMock
|
|
3941
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3942
|
+
.ReturnsAsync(({{name}}?)null); // Repository should filter by tenant
|
|
3943
|
+
|
|
3944
|
+
// Act
|
|
3945
|
+
var result = await _sut.GetByIdAsync(id, _tenantId);
|
|
3946
|
+
|
|
3947
|
+
// Assert
|
|
3948
|
+
result.Should().BeNull();
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
#endregion
|
|
3952
|
+
{{/unless}}
|
|
3953
|
+
|
|
3954
|
+
#region Error Handling Tests
|
|
3955
|
+
|
|
3956
|
+
[Fact]
|
|
3957
|
+
public async Task UpdateAsync_WhenEntityNotFound_ShouldThrow()
|
|
3958
|
+
{
|
|
3959
|
+
// Arrange
|
|
3960
|
+
var id = Guid.NewGuid();
|
|
3961
|
+
_repositoryMock
|
|
3962
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3963
|
+
.ReturnsAsync(({{name}}?)null);
|
|
3964
|
+
|
|
3965
|
+
// Act
|
|
3966
|
+
var act = async () => await _sut.UpdateAsync(id, "new_code");
|
|
3967
|
+
|
|
3968
|
+
// Assert
|
|
3969
|
+
await act.Should().ThrowAsync<InvalidOperationException>()
|
|
3970
|
+
.WithMessage("*not found*");
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
[Fact]
|
|
3974
|
+
public async Task DeleteAsync_WhenEntityNotFound_ShouldReturnFalse()
|
|
3975
|
+
{
|
|
3976
|
+
// Arrange
|
|
3977
|
+
var id = Guid.NewGuid();
|
|
3978
|
+
_repositoryMock
|
|
3979
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3980
|
+
.ReturnsAsync(({{name}}?)null);
|
|
3981
|
+
|
|
3982
|
+
// Act
|
|
3983
|
+
var result = await _sut.DeleteAsync(id);
|
|
3984
|
+
|
|
3985
|
+
// Assert
|
|
3986
|
+
result.Should().BeFalse();
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
#endregion
|
|
3990
|
+
|
|
3991
|
+
{{#if includeEdgeCases}}
|
|
3992
|
+
#region Edge Cases
|
|
3993
|
+
|
|
3994
|
+
[Fact]
|
|
3995
|
+
public async Task CreateAsync_WithEmptyCode_ShouldStillCreate()
|
|
3996
|
+
{
|
|
3997
|
+
// Arrange
|
|
3998
|
+
_repositoryMock
|
|
3999
|
+
.Setup(r => r.AddAsync(It.IsAny<{{name}}>(), It.IsAny<CancellationToken>()))
|
|
4000
|
+
.ReturnsAsync(({{name}} e, CancellationToken _) => e);
|
|
4001
|
+
|
|
4002
|
+
// Act
|
|
4003
|
+
var result = await _sut.CreateAsync({{#unless isSystemEntity}}_tenantId, {{/unless}}"");
|
|
4004
|
+
|
|
4005
|
+
// Assert
|
|
4006
|
+
result.Should().NotBeNull();
|
|
4007
|
+
result.Code.Should().BeEmpty();
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
[Fact]
|
|
4011
|
+
public async Task GetAllAsync_WhenNoEntities_ShouldReturnEmptyList()
|
|
4012
|
+
{
|
|
4013
|
+
// Arrange
|
|
4014
|
+
_repositoryMock
|
|
4015
|
+
.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
|
|
4016
|
+
.ReturnsAsync(new List<{{name}}>());
|
|
4017
|
+
|
|
4018
|
+
// Act
|
|
4019
|
+
var result = await _sut.GetAllAsync();
|
|
4020
|
+
|
|
4021
|
+
// Assert
|
|
4022
|
+
result.Should().BeEmpty();
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
#endregion
|
|
4026
|
+
{{/if}}
|
|
4027
|
+
}
|
|
4028
|
+
`;
|
|
4029
|
+
var controllerTestTemplate = `using System;
|
|
4030
|
+
using System.Collections.Generic;
|
|
4031
|
+
using System.Net;
|
|
4032
|
+
using System.Net.Http;
|
|
4033
|
+
using System.Net.Http.Json;
|
|
4034
|
+
using System.Threading.Tasks;
|
|
4035
|
+
using FluentAssertions;
|
|
4036
|
+
using Microsoft.AspNetCore.Mvc.Testing;
|
|
4037
|
+
using Microsoft.Extensions.DependencyInjection;
|
|
4038
|
+
using Moq;
|
|
4039
|
+
using Xunit;
|
|
4040
|
+
using {{apiNamespace}};
|
|
4041
|
+
using {{applicationNamespace}}.Services;
|
|
4042
|
+
using {{domainNamespace}};
|
|
4043
|
+
|
|
4044
|
+
namespace {{testNamespace}}.Integration.Controllers;
|
|
4045
|
+
|
|
4046
|
+
/// <summary>
|
|
4047
|
+
/// Integration tests for {{name}}Controller
|
|
4048
|
+
/// Tests: HTTP status codes, Authorization, Validation, CRUD operations
|
|
4049
|
+
/// </summary>
|
|
4050
|
+
public class {{name}}ControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
|
4051
|
+
{
|
|
4052
|
+
private readonly WebApplicationFactory<Program> _factory;
|
|
4053
|
+
private readonly HttpClient _client;
|
|
4054
|
+
private readonly Mock<I{{name}}Service> _serviceMock;
|
|
4055
|
+
{{#unless isSystemEntity}}
|
|
4056
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
4057
|
+
{{/unless}}
|
|
4058
|
+
|
|
4059
|
+
public {{name}}ControllerTests(WebApplicationFactory<Program> factory)
|
|
4060
|
+
{
|
|
4061
|
+
_serviceMock = new Mock<I{{name}}Service>();
|
|
4062
|
+
|
|
4063
|
+
_factory = factory.WithWebHostBuilder(builder =>
|
|
4064
|
+
{
|
|
4065
|
+
builder.ConfigureServices(services =>
|
|
4066
|
+
{
|
|
4067
|
+
// Replace the real service with our mock
|
|
4068
|
+
var descriptor = services.SingleOrDefault(
|
|
4069
|
+
d => d.ServiceType == typeof(I{{name}}Service));
|
|
4070
|
+
|
|
4071
|
+
if (descriptor != null)
|
|
4072
|
+
{
|
|
4073
|
+
services.Remove(descriptor);
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
services.AddScoped(_ => _serviceMock.Object);
|
|
4077
|
+
});
|
|
4078
|
+
});
|
|
4079
|
+
|
|
4080
|
+
_client = _factory.CreateClient();
|
|
4081
|
+
{{#unless isSystemEntity}}
|
|
4082
|
+
_client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4083
|
+
{{/unless}}
|
|
4084
|
+
}
|
|
4085
|
+
|
|
4086
|
+
#region GET Tests
|
|
4087
|
+
|
|
4088
|
+
[Fact]
|
|
4089
|
+
public async Task GetAll_WhenAuthorized_ShouldReturn200()
|
|
4090
|
+
{
|
|
4091
|
+
// Arrange
|
|
4092
|
+
var entities = new List<{{name}}>
|
|
4093
|
+
{
|
|
4094
|
+
{{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"entity1"),
|
|
4095
|
+
{{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"entity2"),
|
|
4096
|
+
};
|
|
4097
|
+
_serviceMock
|
|
4098
|
+
.Setup(s => s.GetAllAsync({{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4099
|
+
.ReturnsAsync(entities);
|
|
4100
|
+
|
|
4101
|
+
// Act
|
|
4102
|
+
var response = await _client.GetAsync("/api/{{nameLower}}");
|
|
4103
|
+
|
|
4104
|
+
// Assert
|
|
4105
|
+
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
[Fact]
|
|
4109
|
+
public async Task GetById_WhenEntityExists_ShouldReturn200()
|
|
4110
|
+
{
|
|
4111
|
+
// Arrange
|
|
4112
|
+
var id = Guid.NewGuid();
|
|
4113
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4114
|
+
_serviceMock
|
|
4115
|
+
.Setup(s => s.GetByIdAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4116
|
+
.ReturnsAsync(entity);
|
|
4117
|
+
|
|
4118
|
+
// Act
|
|
4119
|
+
var response = await _client.GetAsync($"/api/{{nameLower}}/{id}");
|
|
4120
|
+
|
|
4121
|
+
// Assert
|
|
4122
|
+
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4125
|
+
[Fact]
|
|
4126
|
+
public async Task GetById_WhenNotFound_ShouldReturn404()
|
|
4127
|
+
{
|
|
4128
|
+
// Arrange
|
|
4129
|
+
var id = Guid.NewGuid();
|
|
4130
|
+
_serviceMock
|
|
4131
|
+
.Setup(s => s.GetByIdAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4132
|
+
.ReturnsAsync(({{name}}?)null);
|
|
4133
|
+
|
|
4134
|
+
// Act
|
|
4135
|
+
var response = await _client.GetAsync($"/api/{{nameLower}}/{id}");
|
|
4136
|
+
|
|
4137
|
+
// Assert
|
|
4138
|
+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
#endregion
|
|
4142
|
+
|
|
4143
|
+
#region POST Tests
|
|
4144
|
+
|
|
4145
|
+
[Fact]
|
|
4146
|
+
public async Task Create_WhenValidData_ShouldReturn201()
|
|
4147
|
+
{
|
|
4148
|
+
// Arrange
|
|
4149
|
+
var request = new { Code = "new_entity" };
|
|
4150
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}request.Code);
|
|
4151
|
+
_serviceMock
|
|
4152
|
+
.Setup(s => s.CreateAsync({{#unless isSystemEntity}}_tenantId, {{/unless}}request.Code, default))
|
|
4153
|
+
.ReturnsAsync(entity);
|
|
4154
|
+
|
|
4155
|
+
// Act
|
|
4156
|
+
var response = await _client.PostAsJsonAsync("/api/{{nameLower}}", request);
|
|
4157
|
+
|
|
4158
|
+
// Assert
|
|
4159
|
+
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
{{#if includeValidation}}
|
|
4163
|
+
[Fact]
|
|
4164
|
+
public async Task Create_WhenInvalidData_ShouldReturn400()
|
|
4165
|
+
{
|
|
4166
|
+
// Arrange
|
|
4167
|
+
var request = new { Code = (string?)null };
|
|
4168
|
+
|
|
4169
|
+
// Act
|
|
4170
|
+
var response = await _client.PostAsJsonAsync("/api/{{nameLower}}", request);
|
|
4171
|
+
|
|
4172
|
+
// Assert
|
|
4173
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4174
|
+
}
|
|
4175
|
+
{{/if}}
|
|
4176
|
+
|
|
4177
|
+
#endregion
|
|
4178
|
+
|
|
4179
|
+
#region PUT Tests
|
|
4180
|
+
|
|
4181
|
+
[Fact]
|
|
4182
|
+
public async Task Update_WhenEntityExists_ShouldReturn200()
|
|
4183
|
+
{
|
|
4184
|
+
// Arrange
|
|
4185
|
+
var id = Guid.NewGuid();
|
|
4186
|
+
var request = new { Code = "updated_code" };
|
|
4187
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4188
|
+
_serviceMock
|
|
4189
|
+
.Setup(s => s.GetByIdAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4190
|
+
.ReturnsAsync(entity);
|
|
4191
|
+
_serviceMock
|
|
4192
|
+
.Setup(s => s.UpdateAsync(id, request.Code, default))
|
|
4193
|
+
.ReturnsAsync(entity);
|
|
4194
|
+
|
|
4195
|
+
// Act
|
|
4196
|
+
var response = await _client.PutAsJsonAsync($"/api/{{nameLower}}/{id}", request);
|
|
4197
|
+
|
|
4198
|
+
// Assert
|
|
4199
|
+
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
[Fact]
|
|
4203
|
+
public async Task Update_WhenNotFound_ShouldReturn404()
|
|
4204
|
+
{
|
|
4205
|
+
// Arrange
|
|
4206
|
+
var id = Guid.NewGuid();
|
|
4207
|
+
var request = new { Code = "updated_code" };
|
|
4208
|
+
_serviceMock
|
|
4209
|
+
.Setup(s => s.GetByIdAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4210
|
+
.ReturnsAsync(({{name}}?)null);
|
|
4211
|
+
|
|
4212
|
+
// Act
|
|
4213
|
+
var response = await _client.PutAsJsonAsync($"/api/{{nameLower}}/{id}", request);
|
|
4214
|
+
|
|
4215
|
+
// Assert
|
|
4216
|
+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
#endregion
|
|
4220
|
+
|
|
4221
|
+
#region DELETE Tests
|
|
4222
|
+
|
|
4223
|
+
[Fact]
|
|
4224
|
+
public async Task Delete_WhenEntityExists_ShouldReturn204()
|
|
4225
|
+
{
|
|
4226
|
+
// Arrange
|
|
4227
|
+
var id = Guid.NewGuid();
|
|
4228
|
+
_serviceMock
|
|
4229
|
+
.Setup(s => s.DeleteAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4230
|
+
.ReturnsAsync(true);
|
|
4231
|
+
|
|
4232
|
+
// Act
|
|
4233
|
+
var response = await _client.DeleteAsync($"/api/{{nameLower}}/{id}");
|
|
4234
|
+
|
|
4235
|
+
// Assert
|
|
4236
|
+
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
|
4237
|
+
}
|
|
4238
|
+
|
|
4239
|
+
[Fact]
|
|
4240
|
+
public async Task Delete_WhenNotFound_ShouldReturn404()
|
|
4241
|
+
{
|
|
4242
|
+
// Arrange
|
|
4243
|
+
var id = Guid.NewGuid();
|
|
4244
|
+
_serviceMock
|
|
4245
|
+
.Setup(s => s.DeleteAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4246
|
+
.ReturnsAsync(false);
|
|
4247
|
+
|
|
4248
|
+
// Act
|
|
4249
|
+
var response = await _client.DeleteAsync($"/api/{{nameLower}}/{id}");
|
|
4250
|
+
|
|
4251
|
+
// Assert
|
|
4252
|
+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
#endregion
|
|
4256
|
+
|
|
4257
|
+
{{#if includeAuthorization}}
|
|
4258
|
+
#region Authorization Tests
|
|
4259
|
+
|
|
4260
|
+
[Fact]
|
|
4261
|
+
public async Task GetAll_WhenUnauthorized_ShouldReturn401()
|
|
4262
|
+
{
|
|
4263
|
+
// Arrange
|
|
4264
|
+
var client = _factory.CreateClient();
|
|
4265
|
+
// Don't add auth header
|
|
4266
|
+
|
|
4267
|
+
// Act
|
|
4268
|
+
var response = await client.GetAsync("/api/{{nameLower}}");
|
|
4269
|
+
|
|
4270
|
+
// Assert
|
|
4271
|
+
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
{{#unless isSystemEntity}}
|
|
4275
|
+
[Fact]
|
|
4276
|
+
public async Task GetById_WhenDifferentTenant_ShouldReturn403()
|
|
4277
|
+
{
|
|
4278
|
+
// Arrange
|
|
4279
|
+
var id = Guid.NewGuid();
|
|
4280
|
+
var client = _factory.CreateClient();
|
|
4281
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", Guid.NewGuid().ToString());
|
|
4282
|
+
|
|
4283
|
+
_serviceMock
|
|
4284
|
+
.Setup(s => s.GetByIdAsync(id, It.IsAny<Guid>(), default))
|
|
4285
|
+
.ReturnsAsync(({{name}}?)null);
|
|
4286
|
+
|
|
4287
|
+
// Act
|
|
4288
|
+
var response = await client.GetAsync($"/api/{{nameLower}}/{id}");
|
|
4289
|
+
|
|
4290
|
+
// Assert
|
|
4291
|
+
response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.NotFound);
|
|
4292
|
+
}
|
|
4293
|
+
{{/unless}}
|
|
4294
|
+
|
|
4295
|
+
#endregion
|
|
4296
|
+
{{/if}}
|
|
4297
|
+
}
|
|
4298
|
+
`;
|
|
4299
|
+
var validatorTestTemplate = `using FluentAssertions;
|
|
4300
|
+
using FluentValidation.TestHelper;
|
|
4301
|
+
using Xunit;
|
|
4302
|
+
using {{applicationNamespace}}.DTOs;
|
|
4303
|
+
using {{applicationNamespace}}.Validators;
|
|
4304
|
+
|
|
4305
|
+
namespace {{testNamespace}}.Unit.Validators;
|
|
4306
|
+
|
|
4307
|
+
/// <summary>
|
|
4308
|
+
/// Unit tests for {{name}} validators
|
|
4309
|
+
/// Tests: Validation rules, Error messages
|
|
4310
|
+
/// </summary>
|
|
4311
|
+
public class {{name}}ValidatorTests
|
|
4312
|
+
{
|
|
4313
|
+
private readonly Create{{name}}DtoValidator _createValidator;
|
|
4314
|
+
private readonly Update{{name}}DtoValidator _updateValidator;
|
|
4315
|
+
|
|
4316
|
+
public {{name}}ValidatorTests()
|
|
4317
|
+
{
|
|
4318
|
+
_createValidator = new Create{{name}}DtoValidator();
|
|
4319
|
+
_updateValidator = new Update{{name}}DtoValidator();
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
#region Create Validator Tests
|
|
4323
|
+
|
|
4324
|
+
[Fact]
|
|
4325
|
+
public void CreateValidator_WhenCodeIsEmpty_ShouldHaveError()
|
|
4326
|
+
{
|
|
4327
|
+
// Arrange
|
|
4328
|
+
var dto = new Create{{name}}Dto { Code = string.Empty };
|
|
4329
|
+
|
|
4330
|
+
// Act
|
|
4331
|
+
var result = _createValidator.TestValidate(dto);
|
|
4332
|
+
|
|
4333
|
+
// Assert
|
|
4334
|
+
result.ShouldHaveValidationErrorFor(x => x.Code)
|
|
4335
|
+
.WithErrorMessage("*required*");
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
[Fact]
|
|
4339
|
+
public void CreateValidator_WhenCodeIsTooLong_ShouldHaveError()
|
|
4340
|
+
{
|
|
4341
|
+
// Arrange
|
|
4342
|
+
var dto = new Create{{name}}Dto { Code = new string('a', 101) };
|
|
4343
|
+
|
|
4344
|
+
// Act
|
|
4345
|
+
var result = _createValidator.TestValidate(dto);
|
|
4346
|
+
|
|
4347
|
+
// Assert
|
|
4348
|
+
result.ShouldHaveValidationErrorFor(x => x.Code)
|
|
4349
|
+
.WithErrorMessage("*100 characters*");
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
[Fact]
|
|
4353
|
+
public void CreateValidator_WhenValidData_ShouldNotHaveErrors()
|
|
4354
|
+
{
|
|
4355
|
+
// Arrange
|
|
4356
|
+
var dto = new Create{{name}}Dto { Code = "valid_code" };
|
|
4357
|
+
|
|
4358
|
+
// Act
|
|
4359
|
+
var result = _createValidator.TestValidate(dto);
|
|
4360
|
+
|
|
4361
|
+
// Assert
|
|
4362
|
+
result.ShouldNotHaveAnyValidationErrors();
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
#endregion
|
|
4366
|
+
|
|
4367
|
+
#region Update Validator Tests
|
|
4368
|
+
|
|
4369
|
+
[Fact]
|
|
4370
|
+
public void UpdateValidator_WhenCodeIsEmpty_ShouldHaveError()
|
|
4371
|
+
{
|
|
4372
|
+
// Arrange
|
|
4373
|
+
var dto = new Update{{name}}Dto { Code = string.Empty };
|
|
4374
|
+
|
|
4375
|
+
// Act
|
|
4376
|
+
var result = _updateValidator.TestValidate(dto);
|
|
4377
|
+
|
|
4378
|
+
// Assert
|
|
4379
|
+
result.ShouldHaveValidationErrorFor(x => x.Code);
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4382
|
+
[Fact]
|
|
4383
|
+
public void UpdateValidator_WhenValidData_ShouldNotHaveErrors()
|
|
4384
|
+
{
|
|
4385
|
+
// Arrange
|
|
4386
|
+
var dto = new Update{{name}}Dto { Code = "updated_code" };
|
|
4387
|
+
|
|
4388
|
+
// Act
|
|
4389
|
+
var result = _updateValidator.TestValidate(dto);
|
|
4390
|
+
|
|
4391
|
+
// Assert
|
|
4392
|
+
result.ShouldNotHaveAnyValidationErrors();
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
#endregion
|
|
4396
|
+
|
|
4397
|
+
{{#if includeEdgeCases}}
|
|
4398
|
+
#region Edge Cases
|
|
4399
|
+
|
|
4400
|
+
[Theory]
|
|
4401
|
+
[InlineData("test-code")]
|
|
4402
|
+
[InlineData("test_code")]
|
|
4403
|
+
[InlineData("test.code")]
|
|
4404
|
+
public void CreateValidator_WhenCodeHasSpecialCharacters_ShouldPass(string code)
|
|
4405
|
+
{
|
|
4406
|
+
// Arrange
|
|
4407
|
+
var dto = new Create{{name}}Dto { Code = code };
|
|
4408
|
+
|
|
4409
|
+
// Act
|
|
4410
|
+
var result = _createValidator.TestValidate(dto);
|
|
4411
|
+
|
|
4412
|
+
// Assert
|
|
4413
|
+
result.ShouldNotHaveValidationErrorFor(x => x.Code);
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
[Theory]
|
|
4417
|
+
[InlineData(" leading")]
|
|
4418
|
+
[InlineData("trailing ")]
|
|
4419
|
+
[InlineData(" both ")]
|
|
4420
|
+
public void CreateValidator_WhenCodeHasWhitespace_ShouldTrimAndValidate(string code)
|
|
4421
|
+
{
|
|
4422
|
+
// Arrange
|
|
4423
|
+
var dto = new Create{{name}}Dto { Code = code };
|
|
4424
|
+
|
|
4425
|
+
// Act
|
|
4426
|
+
var result = _createValidator.TestValidate(dto);
|
|
4427
|
+
|
|
4428
|
+
// Assert
|
|
4429
|
+
// Depending on implementation, may pass or fail
|
|
4430
|
+
// This test documents the expected behavior
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
#endregion
|
|
4434
|
+
{{/if}}
|
|
4435
|
+
}
|
|
4436
|
+
`;
|
|
4437
|
+
var repositoryTestTemplate = `using System;
|
|
4438
|
+
using System.Collections.Generic;
|
|
4439
|
+
using System.Linq;
|
|
4440
|
+
using System.Threading.Tasks;
|
|
4441
|
+
using FluentAssertions;
|
|
4442
|
+
using Microsoft.EntityFrameworkCore;
|
|
4443
|
+
using Xunit;
|
|
4444
|
+
using {{infrastructureNamespace}}.Persistence;
|
|
4445
|
+
using {{infrastructureNamespace}}.Repositories;
|
|
4446
|
+
using {{domainNamespace}};
|
|
4447
|
+
|
|
4448
|
+
namespace {{testNamespace}}.Integration.Repositories;
|
|
4449
|
+
|
|
4450
|
+
/// <summary>
|
|
4451
|
+
/// Integration tests for {{name}}Repository
|
|
4452
|
+
/// Tests: CRUD with DbContext, Queries, Tenant scope
|
|
4453
|
+
/// </summary>
|
|
4454
|
+
public class {{name}}RepositoryTests : IDisposable
|
|
4455
|
+
{
|
|
4456
|
+
private readonly ApplicationDbContext _context;
|
|
4457
|
+
private readonly {{name}}Repository _repository;
|
|
4458
|
+
{{#unless isSystemEntity}}
|
|
4459
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
4460
|
+
{{/unless}}
|
|
4461
|
+
|
|
4462
|
+
public {{name}}RepositoryTests()
|
|
4463
|
+
{
|
|
4464
|
+
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
4465
|
+
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
4466
|
+
.Options;
|
|
4467
|
+
|
|
4468
|
+
_context = new ApplicationDbContext(options);
|
|
4469
|
+
_repository = new {{name}}Repository(_context);
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
public void Dispose()
|
|
4473
|
+
{
|
|
4474
|
+
_context.Dispose();
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
#region GetByIdAsync Tests
|
|
4478
|
+
|
|
4479
|
+
[Fact]
|
|
4480
|
+
public async Task GetByIdAsync_WhenEntityExists_ShouldReturnEntity()
|
|
4481
|
+
{
|
|
4482
|
+
// Arrange
|
|
4483
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4484
|
+
await _context.Set<{{name}}>().AddAsync(entity);
|
|
4485
|
+
await _context.SaveChangesAsync();
|
|
4486
|
+
|
|
4487
|
+
// Act
|
|
4488
|
+
var result = await _repository.GetByIdAsync(entity.Id);
|
|
4489
|
+
|
|
4490
|
+
// Assert
|
|
4491
|
+
result.Should().NotBeNull();
|
|
4492
|
+
result!.Id.Should().Be(entity.Id);
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
[Fact]
|
|
4496
|
+
public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull()
|
|
4497
|
+
{
|
|
4498
|
+
// Arrange
|
|
4499
|
+
var id = Guid.NewGuid();
|
|
4500
|
+
|
|
4501
|
+
// Act
|
|
4502
|
+
var result = await _repository.GetByIdAsync(id);
|
|
4503
|
+
|
|
4504
|
+
// Assert
|
|
4505
|
+
result.Should().BeNull();
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
#endregion
|
|
4509
|
+
|
|
4510
|
+
#region AddAsync Tests
|
|
4511
|
+
|
|
4512
|
+
[Fact]
|
|
4513
|
+
public async Task AddAsync_ShouldPersistEntity()
|
|
4514
|
+
{
|
|
4515
|
+
// Arrange
|
|
4516
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4517
|
+
|
|
4518
|
+
// Act
|
|
4519
|
+
var result = await _repository.AddAsync(entity);
|
|
4520
|
+
await _context.SaveChangesAsync();
|
|
4521
|
+
|
|
4522
|
+
// Assert
|
|
4523
|
+
result.Should().NotBeNull();
|
|
4524
|
+
var persisted = await _context.Set<{{name}}>().FindAsync(entity.Id);
|
|
4525
|
+
persisted.Should().NotBeNull();
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4528
|
+
#endregion
|
|
4529
|
+
|
|
4530
|
+
#region UpdateAsync Tests
|
|
4531
|
+
|
|
4532
|
+
[Fact]
|
|
4533
|
+
public async Task UpdateAsync_ShouldUpdateEntity()
|
|
4534
|
+
{
|
|
4535
|
+
// Arrange
|
|
4536
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"original");
|
|
4537
|
+
await _context.Set<{{name}}>().AddAsync(entity);
|
|
4538
|
+
await _context.SaveChangesAsync();
|
|
4539
|
+
|
|
4540
|
+
// Act
|
|
4541
|
+
entity.Update("updater");
|
|
4542
|
+
await _repository.UpdateAsync(entity);
|
|
4543
|
+
await _context.SaveChangesAsync();
|
|
4544
|
+
|
|
4545
|
+
// Assert
|
|
4546
|
+
var updated = await _context.Set<{{name}}>().FindAsync(entity.Id);
|
|
4547
|
+
updated!.UpdatedBy.Should().Be("updater");
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
#endregion
|
|
4551
|
+
|
|
4552
|
+
#region DeleteAsync Tests
|
|
4553
|
+
|
|
4554
|
+
[Fact]
|
|
4555
|
+
public async Task DeleteAsync_ShouldRemoveEntity()
|
|
4556
|
+
{
|
|
4557
|
+
// Arrange
|
|
4558
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4559
|
+
await _context.Set<{{name}}>().AddAsync(entity);
|
|
4560
|
+
await _context.SaveChangesAsync();
|
|
4561
|
+
|
|
4562
|
+
// Act
|
|
4563
|
+
await _repository.DeleteAsync(entity);
|
|
4564
|
+
await _context.SaveChangesAsync();
|
|
4565
|
+
|
|
4566
|
+
// Assert
|
|
4567
|
+
var deleted = await _context.Set<{{name}}>().FindAsync(entity.Id);
|
|
4568
|
+
deleted.Should().BeNull();
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
#endregion
|
|
4572
|
+
|
|
4573
|
+
{{#unless isSystemEntity}}
|
|
4574
|
+
#region Tenant Isolation Tests
|
|
4575
|
+
|
|
4576
|
+
[Fact]
|
|
4577
|
+
public async Task GetAllByTenantAsync_ShouldOnlyReturnTenantEntities()
|
|
4578
|
+
{
|
|
4579
|
+
// Arrange
|
|
4580
|
+
var otherTenantId = Guid.NewGuid();
|
|
4581
|
+
await _context.Set<{{name}}>().AddRangeAsync(
|
|
4582
|
+
{{name}}.Create(_tenantId, "entity1"),
|
|
4583
|
+
{{name}}.Create(_tenantId, "entity2"),
|
|
4584
|
+
{{name}}.Create(otherTenantId, "other_tenant_entity")
|
|
4585
|
+
);
|
|
4586
|
+
await _context.SaveChangesAsync();
|
|
4587
|
+
|
|
4588
|
+
// Act
|
|
4589
|
+
var result = await _repository.GetAllByTenantAsync(_tenantId);
|
|
4590
|
+
|
|
4591
|
+
// Assert
|
|
4592
|
+
result.Should().HaveCount(2);
|
|
4593
|
+
result.Should().OnlyContain(e => e.TenantId == _tenantId);
|
|
4594
|
+
}
|
|
4595
|
+
|
|
4596
|
+
[Fact]
|
|
4597
|
+
public async Task ExistsAsync_WhenEntityInDifferentTenant_ShouldReturnFalse()
|
|
4598
|
+
{
|
|
4599
|
+
// Arrange
|
|
4600
|
+
var otherTenantId = Guid.NewGuid();
|
|
4601
|
+
var entity = {{name}}.Create(otherTenantId, "test");
|
|
4602
|
+
await _context.Set<{{name}}>().AddAsync(entity);
|
|
4603
|
+
await _context.SaveChangesAsync();
|
|
4604
|
+
|
|
4605
|
+
// Act
|
|
4606
|
+
var result = await _repository.ExistsAsync(entity.Id, _tenantId);
|
|
4607
|
+
|
|
4608
|
+
// Assert
|
|
4609
|
+
result.Should().BeFalse();
|
|
4610
|
+
}
|
|
4611
|
+
|
|
4612
|
+
#endregion
|
|
4613
|
+
{{/unless}}
|
|
4614
|
+
|
|
4615
|
+
{{#if includeSoftDelete}}
|
|
4616
|
+
#region Soft Delete Tests
|
|
4617
|
+
|
|
4618
|
+
[Fact]
|
|
4619
|
+
public async Task GetAllAsync_ShouldExcludeSoftDeletedEntities()
|
|
4620
|
+
{
|
|
4621
|
+
// Arrange
|
|
4622
|
+
var activeEntity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"active");
|
|
4623
|
+
var deletedEntity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"deleted");
|
|
4624
|
+
deletedEntity.SoftDelete("deleter");
|
|
4625
|
+
|
|
4626
|
+
await _context.Set<{{name}}>().AddRangeAsync(activeEntity, deletedEntity);
|
|
4627
|
+
await _context.SaveChangesAsync();
|
|
4628
|
+
|
|
4629
|
+
// Act
|
|
4630
|
+
var result = await _repository.GetAllAsync();
|
|
4631
|
+
|
|
4632
|
+
// Assert
|
|
4633
|
+
result.Should().ContainSingle();
|
|
4634
|
+
result.First().Code.Should().Be("active");
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
[Fact]
|
|
4638
|
+
public async Task GetAllIncludingDeletedAsync_ShouldIncludeSoftDeletedEntities()
|
|
4639
|
+
{
|
|
4640
|
+
// Arrange
|
|
4641
|
+
var activeEntity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"active");
|
|
4642
|
+
var deletedEntity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"deleted");
|
|
4643
|
+
deletedEntity.SoftDelete("deleter");
|
|
4644
|
+
|
|
4645
|
+
await _context.Set<{{name}}>().AddRangeAsync(activeEntity, deletedEntity);
|
|
4646
|
+
await _context.SaveChangesAsync();
|
|
4647
|
+
|
|
4648
|
+
// Act
|
|
4649
|
+
var result = await _repository.GetAllIncludingDeletedAsync();
|
|
4650
|
+
|
|
4651
|
+
// Assert
|
|
4652
|
+
result.Should().HaveCount(2);
|
|
4653
|
+
}
|
|
4654
|
+
|
|
4655
|
+
#endregion
|
|
4656
|
+
{{/if}}
|
|
4657
|
+
}
|
|
4658
|
+
`;
|
|
4659
|
+
var securityTestTemplate = `using System;
|
|
4660
|
+
using System.Net;
|
|
4661
|
+
using System.Net.Http;
|
|
4662
|
+
using System.Threading.Tasks;
|
|
4663
|
+
using FluentAssertions;
|
|
4664
|
+
using Microsoft.AspNetCore.Mvc.Testing;
|
|
4665
|
+
using Xunit;
|
|
4666
|
+
using {{apiNamespace}};
|
|
4667
|
+
|
|
4668
|
+
namespace {{testNamespace}}.Security;
|
|
4669
|
+
|
|
4670
|
+
/// <summary>
|
|
4671
|
+
/// Security tests for {{name}}
|
|
4672
|
+
/// Tests: Tenant isolation, Authorization, Input validation, Injection prevention
|
|
4673
|
+
/// </summary>
|
|
4674
|
+
public class {{name}}SecurityTests : IClassFixture<WebApplicationFactory<Program>>
|
|
4675
|
+
{
|
|
4676
|
+
private readonly WebApplicationFactory<Program> _factory;
|
|
4677
|
+
{{#unless isSystemEntity}}
|
|
4678
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
4679
|
+
{{/unless}}
|
|
4680
|
+
|
|
4681
|
+
public {{name}}SecurityTests(WebApplicationFactory<Program> factory)
|
|
4682
|
+
{
|
|
4683
|
+
_factory = factory;
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4686
|
+
{{#unless isSystemEntity}}
|
|
4687
|
+
#region Tenant Isolation Tests
|
|
4688
|
+
|
|
4689
|
+
[Fact]
|
|
4690
|
+
public async Task Request_WithoutTenantHeader_ShouldReturn400()
|
|
4691
|
+
{
|
|
4692
|
+
// Arrange
|
|
4693
|
+
var client = _factory.CreateClient();
|
|
4694
|
+
// Intentionally not adding X-Tenant-Id header
|
|
4695
|
+
|
|
4696
|
+
// Act
|
|
4697
|
+
var response = await client.GetAsync("/api/{{nameLower}}");
|
|
4698
|
+
|
|
4699
|
+
// Assert
|
|
4700
|
+
response.StatusCode.Should().BeOneOf(
|
|
4701
|
+
HttpStatusCode.BadRequest,
|
|
4702
|
+
HttpStatusCode.Unauthorized
|
|
4703
|
+
);
|
|
4704
|
+
}
|
|
4705
|
+
|
|
4706
|
+
[Fact]
|
|
4707
|
+
public async Task Request_WithInvalidTenantId_ShouldReturn400()
|
|
4708
|
+
{
|
|
4709
|
+
// Arrange
|
|
4710
|
+
var client = _factory.CreateClient();
|
|
4711
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", "invalid-guid");
|
|
4712
|
+
|
|
4713
|
+
// Act
|
|
4714
|
+
var response = await client.GetAsync("/api/{{nameLower}}");
|
|
4715
|
+
|
|
4716
|
+
// Assert
|
|
4717
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4718
|
+
}
|
|
4719
|
+
|
|
4720
|
+
[Fact]
|
|
4721
|
+
public async Task Request_WithEmptyTenantId_ShouldReturn400()
|
|
4722
|
+
{
|
|
4723
|
+
// Arrange
|
|
4724
|
+
var client = _factory.CreateClient();
|
|
4725
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", Guid.Empty.ToString());
|
|
4726
|
+
|
|
4727
|
+
// Act
|
|
4728
|
+
var response = await client.GetAsync("/api/{{nameLower}}");
|
|
4729
|
+
|
|
4730
|
+
// Assert
|
|
4731
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
#endregion
|
|
4735
|
+
{{/unless}}
|
|
4736
|
+
|
|
4737
|
+
#region Input Validation Security Tests
|
|
4738
|
+
|
|
4739
|
+
[Theory]
|
|
4740
|
+
[InlineData("<script>alert('xss')</script>")]
|
|
4741
|
+
[InlineData("'; DROP TABLE users; --")]
|
|
4742
|
+
[InlineData("{{'{{'}}constructor.prototype{{'}}'}}")]
|
|
4743
|
+
public async Task Create_WithMaliciousInput_ShouldSanitizeOrReject(string maliciousInput)
|
|
4744
|
+
{
|
|
4745
|
+
// Arrange
|
|
4746
|
+
var client = _factory.CreateClient();
|
|
4747
|
+
{{#unless isSystemEntity}}
|
|
4748
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4749
|
+
{{/unless}}
|
|
4750
|
+
var request = new { Code = maliciousInput };
|
|
4751
|
+
|
|
4752
|
+
// Act
|
|
4753
|
+
var response = await client.PostAsJsonAsync("/api/{{nameLower}}", request);
|
|
4754
|
+
|
|
4755
|
+
// Assert
|
|
4756
|
+
// Should either reject (400) or sanitize (201 with cleaned data)
|
|
4757
|
+
response.StatusCode.Should().BeOneOf(
|
|
4758
|
+
HttpStatusCode.BadRequest,
|
|
4759
|
+
HttpStatusCode.Created,
|
|
4760
|
+
HttpStatusCode.UnprocessableEntity
|
|
4761
|
+
);
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
[Theory]
|
|
4765
|
+
[InlineData("../../../etc/passwd")]
|
|
4766
|
+
[InlineData("..\\\\..\\\\..\\\\windows\\\\system32")]
|
|
4767
|
+
[InlineData("....//....//....//etc/passwd")]
|
|
4768
|
+
public async Task Create_WithPathTraversalAttempt_ShouldReject(string pathTraversal)
|
|
4769
|
+
{
|
|
4770
|
+
// Arrange
|
|
4771
|
+
var client = _factory.CreateClient();
|
|
4772
|
+
{{#unless isSystemEntity}}
|
|
4773
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4774
|
+
{{/unless}}
|
|
4775
|
+
var request = new { Code = pathTraversal };
|
|
4776
|
+
|
|
4777
|
+
// Act
|
|
4778
|
+
var response = await client.PostAsJsonAsync("/api/{{nameLower}}", request);
|
|
4779
|
+
|
|
4780
|
+
// Assert
|
|
4781
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
#endregion
|
|
4785
|
+
|
|
4786
|
+
#region ID Manipulation Tests
|
|
4787
|
+
|
|
4788
|
+
[Fact]
|
|
4789
|
+
public async Task GetById_WithNegativeId_ShouldReturn400Or404()
|
|
4790
|
+
{
|
|
4791
|
+
// Arrange
|
|
4792
|
+
var client = _factory.CreateClient();
|
|
4793
|
+
{{#unless isSystemEntity}}
|
|
4794
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4795
|
+
{{/unless}}
|
|
4796
|
+
|
|
4797
|
+
// Act
|
|
4798
|
+
var response = await client.GetAsync("/api/{{nameLower}}/00000000-0000-0000-0000-000000000000");
|
|
4799
|
+
|
|
4800
|
+
// Assert
|
|
4801
|
+
response.StatusCode.Should().BeOneOf(
|
|
4802
|
+
HttpStatusCode.BadRequest,
|
|
4803
|
+
HttpStatusCode.NotFound
|
|
4804
|
+
);
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
[Fact]
|
|
4808
|
+
public async Task GetById_WithMalformedGuid_ShouldReturn400()
|
|
4809
|
+
{
|
|
4810
|
+
// Arrange
|
|
4811
|
+
var client = _factory.CreateClient();
|
|
4812
|
+
{{#unless isSystemEntity}}
|
|
4813
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4814
|
+
{{/unless}}
|
|
4815
|
+
|
|
4816
|
+
// Act
|
|
4817
|
+
var response = await client.GetAsync("/api/{{nameLower}}/not-a-guid");
|
|
4818
|
+
|
|
4819
|
+
// Assert
|
|
4820
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4821
|
+
}
|
|
4822
|
+
|
|
4823
|
+
#endregion
|
|
4824
|
+
|
|
4825
|
+
#region Rate Limiting Tests (if applicable)
|
|
4826
|
+
|
|
4827
|
+
[Fact]
|
|
4828
|
+
public async Task MultipleRapidRequests_ShouldNotCauseDenialOfService()
|
|
4829
|
+
{
|
|
4830
|
+
// Arrange
|
|
4831
|
+
var client = _factory.CreateClient();
|
|
4832
|
+
{{#unless isSystemEntity}}
|
|
4833
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4834
|
+
{{/unless}}
|
|
4835
|
+
var tasks = new List<Task<HttpResponseMessage>>();
|
|
4836
|
+
|
|
4837
|
+
// Act
|
|
4838
|
+
for (int i = 0; i < 10; i++)
|
|
4839
|
+
{
|
|
4840
|
+
tasks.Add(client.GetAsync("/api/{{nameLower}}"));
|
|
4841
|
+
}
|
|
4842
|
+
var responses = await Task.WhenAll(tasks);
|
|
4843
|
+
|
|
4844
|
+
// Assert
|
|
4845
|
+
// Should handle without crashing (may return 429 if rate limiting is enabled)
|
|
4846
|
+
responses.Should().OnlyContain(r =>
|
|
4847
|
+
r.StatusCode == HttpStatusCode.OK ||
|
|
4848
|
+
r.StatusCode == HttpStatusCode.TooManyRequests);
|
|
4849
|
+
}
|
|
4850
|
+
|
|
4851
|
+
#endregion
|
|
4852
|
+
}
|
|
4853
|
+
`;
|
|
4854
|
+
async function handleScaffoldTests(args, config) {
|
|
4855
|
+
const input = ScaffoldTestsInputSchema.parse(args);
|
|
4856
|
+
const dryRun = input.options?.dryRun || false;
|
|
4857
|
+
logger.info("Scaffolding tests", { target: input.target, name: input.name, dryRun });
|
|
4858
|
+
const structure = await findSmartStackStructure(config.smartstack.projectPath);
|
|
4859
|
+
const result = {
|
|
4860
|
+
success: true,
|
|
4861
|
+
files: [],
|
|
4862
|
+
instructions: []
|
|
4863
|
+
};
|
|
4864
|
+
const options = {
|
|
4865
|
+
includeEdgeCases: input.options?.includeEdgeCases ?? true,
|
|
4866
|
+
includeTenantIsolation: input.options?.includeTenantIsolation ?? true,
|
|
4867
|
+
includeSoftDelete: input.options?.includeSoftDelete ?? true,
|
|
4868
|
+
includeAudit: input.options?.includeAudit ?? true,
|
|
4869
|
+
includeValidation: input.options?.includeValidation ?? true,
|
|
4870
|
+
includeAuthorization: input.options?.includeAuthorization ?? false,
|
|
4871
|
+
isSystemEntity: input.options?.isSystemEntity ?? false
|
|
4872
|
+
};
|
|
4873
|
+
const testTypes = input.testTypes || ["unit"];
|
|
4874
|
+
try {
|
|
4875
|
+
switch (input.target) {
|
|
4876
|
+
case "entity":
|
|
4877
|
+
await scaffoldEntityTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4878
|
+
break;
|
|
4879
|
+
case "service":
|
|
4880
|
+
await scaffoldServiceTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4881
|
+
break;
|
|
4882
|
+
case "controller":
|
|
4883
|
+
await scaffoldControllerTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4884
|
+
break;
|
|
4885
|
+
case "validator":
|
|
4886
|
+
await scaffoldValidatorTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4887
|
+
break;
|
|
4888
|
+
case "repository":
|
|
4889
|
+
await scaffoldRepositoryTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4890
|
+
break;
|
|
4891
|
+
case "all":
|
|
4892
|
+
await scaffoldEntityTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4893
|
+
await scaffoldServiceTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4894
|
+
await scaffoldControllerTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4895
|
+
await scaffoldValidatorTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4896
|
+
await scaffoldRepositoryTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4897
|
+
break;
|
|
4898
|
+
}
|
|
4899
|
+
} catch (error) {
|
|
4900
|
+
result.success = false;
|
|
4901
|
+
result.instructions.push(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
4902
|
+
}
|
|
4903
|
+
return formatTestResult(result, input.target, input.name, dryRun);
|
|
4904
|
+
}
|
|
4905
|
+
async function scaffoldEntityTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
4906
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
4907
|
+
const domainNamespace = config.conventions.namespaces.domain;
|
|
4908
|
+
const context = {
|
|
4909
|
+
name,
|
|
4910
|
+
testNamespace,
|
|
4911
|
+
domainNamespace,
|
|
4912
|
+
...options
|
|
4913
|
+
};
|
|
4914
|
+
if (testTypes.includes("unit")) {
|
|
4915
|
+
const content = Handlebars2.compile(entityTestTemplate)(context);
|
|
4916
|
+
const testPath = path10.join(structure.root, "Tests", "Unit", "Domain", `${name}Tests.cs`);
|
|
4917
|
+
validatePathSecurity(testPath, structure.root);
|
|
4918
|
+
if (!dryRun) {
|
|
4919
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
4920
|
+
await writeText(testPath, content);
|
|
4921
|
+
}
|
|
4922
|
+
result.files.push({
|
|
4923
|
+
path: path10.relative(structure.root, testPath),
|
|
4924
|
+
content,
|
|
4925
|
+
type: "created"
|
|
4926
|
+
});
|
|
4927
|
+
}
|
|
4928
|
+
if (testTypes.includes("security")) {
|
|
4929
|
+
const securityContent = Handlebars2.compile(securityTestTemplate)({
|
|
4930
|
+
...context,
|
|
4931
|
+
nameLower: name.charAt(0).toLowerCase() + name.slice(1),
|
|
4932
|
+
apiNamespace: config.conventions.namespaces.api
|
|
4933
|
+
});
|
|
4934
|
+
const securityPath = path10.join(structure.root, "Tests", "Security", `${name}SecurityTests.cs`);
|
|
4935
|
+
validatePathSecurity(securityPath, structure.root);
|
|
4936
|
+
if (!dryRun) {
|
|
4937
|
+
await ensureDirectory(path10.dirname(securityPath));
|
|
4938
|
+
await writeText(securityPath, securityContent);
|
|
4939
|
+
}
|
|
4940
|
+
result.files.push({
|
|
4941
|
+
path: path10.relative(structure.root, securityPath),
|
|
4942
|
+
content: securityContent,
|
|
4943
|
+
type: "created"
|
|
4944
|
+
});
|
|
4945
|
+
}
|
|
4946
|
+
result.instructions.push(`Add package reference: <PackageReference Include="FluentAssertions" Version="6.*" />`);
|
|
4947
|
+
result.instructions.push(`Add package reference: <PackageReference Include="xunit" Version="2.*" />`);
|
|
4948
|
+
}
|
|
4949
|
+
async function scaffoldServiceTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
4950
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
4951
|
+
const applicationNamespace = config.conventions.namespaces.application;
|
|
4952
|
+
const domainNamespace = config.conventions.namespaces.domain;
|
|
4953
|
+
const context = {
|
|
4954
|
+
name,
|
|
4955
|
+
testNamespace,
|
|
4956
|
+
applicationNamespace,
|
|
4957
|
+
domainNamespace,
|
|
4958
|
+
...options
|
|
4959
|
+
};
|
|
4960
|
+
if (testTypes.includes("unit")) {
|
|
4961
|
+
const content = Handlebars2.compile(serviceTestTemplate)(context);
|
|
4962
|
+
const testPath = path10.join(structure.root, "Tests", "Unit", "Services", `${name}ServiceTests.cs`);
|
|
4963
|
+
validatePathSecurity(testPath, structure.root);
|
|
4964
|
+
if (!dryRun) {
|
|
4965
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
4966
|
+
await writeText(testPath, content);
|
|
4967
|
+
}
|
|
4968
|
+
result.files.push({
|
|
4969
|
+
path: path10.relative(structure.root, testPath),
|
|
4970
|
+
content,
|
|
4971
|
+
type: "created"
|
|
4972
|
+
});
|
|
4973
|
+
}
|
|
4974
|
+
result.instructions.push(`Add package reference: <PackageReference Include="Moq" Version="4.*" />`);
|
|
4975
|
+
}
|
|
4976
|
+
async function scaffoldControllerTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
4977
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
4978
|
+
const applicationNamespace = config.conventions.namespaces.application;
|
|
4979
|
+
const domainNamespace = config.conventions.namespaces.domain;
|
|
4980
|
+
const apiNamespace = config.conventions.namespaces.api;
|
|
4981
|
+
const context = {
|
|
4982
|
+
name,
|
|
4983
|
+
nameLower: name.charAt(0).toLowerCase() + name.slice(1),
|
|
4984
|
+
testNamespace,
|
|
4985
|
+
applicationNamespace,
|
|
4986
|
+
domainNamespace,
|
|
4987
|
+
apiNamespace,
|
|
4988
|
+
...options
|
|
4989
|
+
};
|
|
4990
|
+
if (testTypes.includes("integration")) {
|
|
4991
|
+
const content = Handlebars2.compile(controllerTestTemplate)(context);
|
|
4992
|
+
const testPath = path10.join(structure.root, "Tests", "Integration", "Controllers", `${name}ControllerTests.cs`);
|
|
4993
|
+
validatePathSecurity(testPath, structure.root);
|
|
4994
|
+
if (!dryRun) {
|
|
4995
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
4996
|
+
await writeText(testPath, content);
|
|
4997
|
+
}
|
|
4998
|
+
result.files.push({
|
|
4999
|
+
path: path10.relative(structure.root, testPath),
|
|
5000
|
+
content,
|
|
5001
|
+
type: "created"
|
|
5002
|
+
});
|
|
5003
|
+
}
|
|
5004
|
+
if (testTypes.includes("security")) {
|
|
5005
|
+
const securityContent = Handlebars2.compile(securityTestTemplate)(context);
|
|
5006
|
+
const securityPath = path10.join(structure.root, "Tests", "Security", `${name}SecurityTests.cs`);
|
|
5007
|
+
validatePathSecurity(securityPath, structure.root);
|
|
5008
|
+
if (!dryRun) {
|
|
5009
|
+
await ensureDirectory(path10.dirname(securityPath));
|
|
5010
|
+
await writeText(securityPath, securityContent);
|
|
5011
|
+
}
|
|
5012
|
+
result.files.push({
|
|
5013
|
+
path: path10.relative(structure.root, securityPath),
|
|
5014
|
+
content: securityContent,
|
|
5015
|
+
type: "created"
|
|
5016
|
+
});
|
|
5017
|
+
}
|
|
5018
|
+
result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.*" />`);
|
|
5019
|
+
}
|
|
5020
|
+
async function scaffoldValidatorTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
5021
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
5022
|
+
const applicationNamespace = config.conventions.namespaces.application;
|
|
5023
|
+
const context = {
|
|
5024
|
+
name,
|
|
5025
|
+
testNamespace,
|
|
5026
|
+
applicationNamespace,
|
|
5027
|
+
...options
|
|
5028
|
+
};
|
|
5029
|
+
if (testTypes.includes("unit")) {
|
|
5030
|
+
const content = Handlebars2.compile(validatorTestTemplate)(context);
|
|
5031
|
+
const testPath = path10.join(structure.root, "Tests", "Unit", "Validators", `${name}ValidatorTests.cs`);
|
|
5032
|
+
validatePathSecurity(testPath, structure.root);
|
|
5033
|
+
if (!dryRun) {
|
|
5034
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
5035
|
+
await writeText(testPath, content);
|
|
5036
|
+
}
|
|
5037
|
+
result.files.push({
|
|
5038
|
+
path: path10.relative(structure.root, testPath),
|
|
5039
|
+
content,
|
|
5040
|
+
type: "created"
|
|
5041
|
+
});
|
|
5042
|
+
}
|
|
5043
|
+
result.instructions.push(`Add package reference: <PackageReference Include="FluentValidation.TestHelper" Version="11.*" />`);
|
|
5044
|
+
}
|
|
5045
|
+
async function scaffoldRepositoryTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
5046
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
5047
|
+
const infrastructureNamespace = config.conventions.namespaces.infrastructure;
|
|
5048
|
+
const domainNamespace = config.conventions.namespaces.domain;
|
|
5049
|
+
const context = {
|
|
5050
|
+
name,
|
|
5051
|
+
testNamespace,
|
|
5052
|
+
infrastructureNamespace,
|
|
5053
|
+
domainNamespace,
|
|
5054
|
+
...options
|
|
5055
|
+
};
|
|
5056
|
+
if (testTypes.includes("integration")) {
|
|
5057
|
+
const content = Handlebars2.compile(repositoryTestTemplate)(context);
|
|
5058
|
+
const testPath = path10.join(structure.root, "Tests", "Integration", "Repositories", `${name}RepositoryTests.cs`);
|
|
5059
|
+
validatePathSecurity(testPath, structure.root);
|
|
5060
|
+
if (!dryRun) {
|
|
5061
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
5062
|
+
await writeText(testPath, content);
|
|
5063
|
+
}
|
|
5064
|
+
result.files.push({
|
|
5065
|
+
path: path10.relative(structure.root, testPath),
|
|
5066
|
+
content,
|
|
5067
|
+
type: "created"
|
|
5068
|
+
});
|
|
5069
|
+
}
|
|
5070
|
+
result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.*" />`);
|
|
5071
|
+
}
|
|
5072
|
+
function formatTestResult(result, _target, name, dryRun) {
|
|
5073
|
+
const lines = [];
|
|
5074
|
+
lines.push(`# Scaffold Tests: ${name}`);
|
|
5075
|
+
lines.push("");
|
|
5076
|
+
if (dryRun) {
|
|
5077
|
+
lines.push("> **DRY RUN MODE** - No files were written");
|
|
5078
|
+
lines.push("");
|
|
5079
|
+
}
|
|
5080
|
+
if (!result.success) {
|
|
5081
|
+
lines.push("## Error");
|
|
5082
|
+
lines.push("");
|
|
5083
|
+
lines.push(result.instructions.join("\n"));
|
|
5084
|
+
return lines.join("\n");
|
|
5085
|
+
}
|
|
5086
|
+
lines.push(`## Generated Test Files (${result.files.length})`);
|
|
5087
|
+
lines.push("");
|
|
5088
|
+
for (const file of result.files) {
|
|
5089
|
+
lines.push(`### \`${file.path}\``);
|
|
5090
|
+
lines.push("");
|
|
5091
|
+
lines.push("```csharp");
|
|
5092
|
+
lines.push(file.content);
|
|
5093
|
+
lines.push("```");
|
|
5094
|
+
lines.push("");
|
|
5095
|
+
}
|
|
5096
|
+
if (result.instructions.length > 0) {
|
|
5097
|
+
lines.push("## Next Steps");
|
|
5098
|
+
lines.push("");
|
|
5099
|
+
for (const instruction of result.instructions) {
|
|
5100
|
+
lines.push(`- ${instruction}`);
|
|
5101
|
+
}
|
|
5102
|
+
lines.push("");
|
|
5103
|
+
}
|
|
5104
|
+
lines.push("## Test Conventions Applied");
|
|
5105
|
+
lines.push("");
|
|
5106
|
+
lines.push("- **Naming**: `{MethodName}_When{Condition}_Should{ExpectedResult}`");
|
|
5107
|
+
lines.push("- **Pattern**: AAA (Arrange-Act-Assert)");
|
|
5108
|
+
lines.push("- **Assertions**: FluentAssertions (`.Should()`)");
|
|
5109
|
+
lines.push("- **Mocking**: Moq (`Mock<T>`)");
|
|
5110
|
+
lines.push("");
|
|
5111
|
+
return lines.join("\n");
|
|
5112
|
+
}
|
|
5113
|
+
|
|
5114
|
+
// src/tools/analyze-test-coverage.ts
|
|
5115
|
+
import path11 from "path";
|
|
5116
|
+
var analyzeTestCoverageTool = {
|
|
5117
|
+
name: "analyze_test_coverage",
|
|
5118
|
+
description: "Analyze test coverage for a SmartStack project. Identifies entities, services, and controllers without tests, calculates coverage ratios, and provides recommendations.",
|
|
5119
|
+
inputSchema: {
|
|
5120
|
+
type: "object",
|
|
5121
|
+
properties: {
|
|
5122
|
+
path: {
|
|
5123
|
+
type: "string",
|
|
5124
|
+
description: "Project path to analyze (default: configured SmartStack path)"
|
|
5125
|
+
},
|
|
5126
|
+
scope: {
|
|
5127
|
+
type: "string",
|
|
5128
|
+
enum: ["entity", "service", "controller", "all"],
|
|
5129
|
+
default: "all",
|
|
5130
|
+
description: "Scope of analysis"
|
|
5131
|
+
},
|
|
5132
|
+
outputFormat: {
|
|
5133
|
+
type: "string",
|
|
5134
|
+
enum: ["summary", "detailed", "json"],
|
|
5135
|
+
default: "summary",
|
|
5136
|
+
description: "Output format"
|
|
5137
|
+
},
|
|
5138
|
+
includeRecommendations: {
|
|
5139
|
+
type: "boolean",
|
|
5140
|
+
default: true,
|
|
5141
|
+
description: "Include recommendations for missing tests"
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
5144
|
+
}
|
|
5145
|
+
};
|
|
5146
|
+
async function handleAnalyzeTestCoverage(args, config) {
|
|
5147
|
+
const input = AnalyzeTestCoverageInputSchema.parse(args);
|
|
5148
|
+
const projectPath = input.path || config.smartstack.projectPath;
|
|
5149
|
+
logger.info("Analyzing test coverage", { projectPath, scope: input.scope });
|
|
5150
|
+
const structure = await findSmartStackStructure(projectPath);
|
|
5151
|
+
const result = {
|
|
5152
|
+
summary: { total: 0, tested: 0, coverage: 0 },
|
|
5153
|
+
entities: { total: 0, tested: 0, coverage: 0, items: [], missingTests: [] },
|
|
5154
|
+
services: { total: 0, tested: 0, coverage: 0, items: [], missingTests: [] },
|
|
5155
|
+
controllers: { total: 0, tested: 0, coverage: 0, items: [], missingTests: [] },
|
|
5156
|
+
missing: [],
|
|
5157
|
+
recommendations: []
|
|
5158
|
+
};
|
|
5159
|
+
try {
|
|
5160
|
+
if (input.scope === "all" || input.scope === "entity") {
|
|
5161
|
+
await analyzeEntityCoverage(structure, result);
|
|
5162
|
+
}
|
|
5163
|
+
if (input.scope === "all" || input.scope === "service") {
|
|
5164
|
+
await analyzeServiceCoverage(structure, result);
|
|
5165
|
+
}
|
|
5166
|
+
if (input.scope === "all" || input.scope === "controller") {
|
|
5167
|
+
await analyzeControllerCoverage(structure, result);
|
|
5168
|
+
}
|
|
5169
|
+
result.summary.total = result.entities.total + result.services.total + result.controllers.total;
|
|
5170
|
+
result.summary.tested = result.entities.tested + result.services.tested + result.controllers.tested;
|
|
5171
|
+
result.summary.coverage = result.summary.total > 0 ? Math.round(result.summary.tested / result.summary.total * 100) : 0;
|
|
5172
|
+
if (input.includeRecommendations) {
|
|
5173
|
+
generateRecommendations(result);
|
|
5174
|
+
}
|
|
5175
|
+
} catch (error) {
|
|
5176
|
+
logger.error("Error analyzing test coverage", error);
|
|
5177
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
5178
|
+
}
|
|
5179
|
+
return formatCoverageResult(result, input.outputFormat);
|
|
5180
|
+
}
|
|
5181
|
+
async function analyzeEntityCoverage(structure, result) {
|
|
5182
|
+
if (!structure.domain) {
|
|
5183
|
+
result.recommendations.push("Domain layer not found - cannot analyze entity coverage");
|
|
5184
|
+
return;
|
|
5185
|
+
}
|
|
5186
|
+
const entityFiles = await findFiles("**/*.cs", { cwd: structure.domain });
|
|
5187
|
+
const entityNames = extractComponentNames(entityFiles, ["Entity", "Aggregate"]);
|
|
5188
|
+
const testPath = path11.join(structure.root, "Tests", "Unit", "Domain");
|
|
5189
|
+
let testFiles = [];
|
|
5190
|
+
try {
|
|
5191
|
+
testFiles = await findFiles("**/*Tests.cs", { cwd: testPath });
|
|
5192
|
+
} catch {
|
|
5193
|
+
}
|
|
5194
|
+
const testedEntities = extractTestedNames(testFiles);
|
|
5195
|
+
result.entities.total = entityNames.length;
|
|
5196
|
+
result.entities.items = entityNames;
|
|
5197
|
+
result.entities.tested = entityNames.filter((e) => testedEntities.includes(e)).length;
|
|
5198
|
+
result.entities.coverage = result.entities.total > 0 ? Math.round(result.entities.tested / result.entities.total * 100) : 0;
|
|
5199
|
+
result.entities.missingTests = entityNames.filter((e) => !testedEntities.includes(e));
|
|
5200
|
+
for (const name of result.entities.missingTests) {
|
|
5201
|
+
result.missing.push({
|
|
5202
|
+
name,
|
|
5203
|
+
type: "entity",
|
|
5204
|
+
priority: determinePriority(name, "entity"),
|
|
5205
|
+
recommendation: `Create unit tests for ${name} entity (factory methods, soft delete, audit trail)`,
|
|
5206
|
+
suggestedTestFile: `Tests/Unit/Domain/${name}Tests.cs`
|
|
5207
|
+
});
|
|
5208
|
+
}
|
|
5209
|
+
}
|
|
5210
|
+
async function analyzeServiceCoverage(structure, result) {
|
|
5211
|
+
if (!structure.application) {
|
|
5212
|
+
result.recommendations.push("Application layer not found - cannot analyze service coverage");
|
|
5213
|
+
return;
|
|
5214
|
+
}
|
|
5215
|
+
const serviceFiles = await findFiles("**/I*Service.cs", { cwd: structure.application });
|
|
5216
|
+
const serviceNames = serviceFiles.map((f) => {
|
|
5217
|
+
const basename = path11.basename(f, ".cs");
|
|
5218
|
+
return basename.startsWith("I") ? basename.slice(1) : basename;
|
|
5219
|
+
}).filter((n) => n.endsWith("Service")).map((n) => n.replace(/Service$/, ""));
|
|
5220
|
+
const testPath = path11.join(structure.root, "Tests", "Unit", "Services");
|
|
5221
|
+
let testFiles = [];
|
|
5222
|
+
try {
|
|
5223
|
+
testFiles = await findFiles("**/*ServiceTests.cs", { cwd: testPath });
|
|
5224
|
+
} catch {
|
|
5225
|
+
}
|
|
5226
|
+
const testedServices = testFiles.map((f) => {
|
|
5227
|
+
const basename = path11.basename(f, ".cs");
|
|
5228
|
+
return basename.replace(/ServiceTests$/, "");
|
|
5229
|
+
});
|
|
5230
|
+
result.services.total = serviceNames.length;
|
|
5231
|
+
result.services.items = serviceNames;
|
|
5232
|
+
result.services.tested = serviceNames.filter((s) => testedServices.includes(s)).length;
|
|
5233
|
+
result.services.coverage = result.services.total > 0 ? Math.round(result.services.tested / result.services.total * 100) : 0;
|
|
5234
|
+
result.services.missingTests = serviceNames.filter((s) => !testedServices.includes(s));
|
|
5235
|
+
for (const name of result.services.missingTests) {
|
|
5236
|
+
result.missing.push({
|
|
5237
|
+
name,
|
|
5238
|
+
type: "service",
|
|
5239
|
+
priority: determinePriority(name, "service"),
|
|
5240
|
+
recommendation: `Create unit tests for ${name}Service (CRUD operations, business logic, error handling)`,
|
|
5241
|
+
suggestedTestFile: `Tests/Unit/Services/${name}ServiceTests.cs`
|
|
5242
|
+
});
|
|
5243
|
+
}
|
|
5244
|
+
}
|
|
5245
|
+
async function analyzeControllerCoverage(structure, result) {
|
|
5246
|
+
if (!structure.api) {
|
|
5247
|
+
result.recommendations.push("API layer not found - cannot analyze controller coverage");
|
|
5248
|
+
return;
|
|
5249
|
+
}
|
|
5250
|
+
const controllerFiles = await findFiles("**/*Controller.cs", { cwd: structure.api });
|
|
5251
|
+
const controllerNames = controllerFiles.map((f) => {
|
|
5252
|
+
const basename = path11.basename(f, ".cs");
|
|
5253
|
+
return basename.replace(/Controller$/, "");
|
|
5254
|
+
}).filter((n) => n !== "Base");
|
|
5255
|
+
const testPath = path11.join(structure.root, "Tests", "Integration", "Controllers");
|
|
5256
|
+
let testFiles = [];
|
|
5257
|
+
try {
|
|
5258
|
+
testFiles = await findFiles("**/*ControllerTests.cs", { cwd: testPath });
|
|
5259
|
+
} catch {
|
|
5260
|
+
}
|
|
5261
|
+
const testedControllers = testFiles.map((f) => {
|
|
5262
|
+
const basename = path11.basename(f, ".cs");
|
|
5263
|
+
return basename.replace(/ControllerTests$/, "");
|
|
5264
|
+
});
|
|
5265
|
+
result.controllers.total = controllerNames.length;
|
|
5266
|
+
result.controllers.items = controllerNames;
|
|
5267
|
+
result.controllers.tested = controllerNames.filter((c) => testedControllers.includes(c)).length;
|
|
5268
|
+
result.controllers.coverage = result.controllers.total > 0 ? Math.round(result.controllers.tested / result.controllers.total * 100) : 0;
|
|
5269
|
+
result.controllers.missingTests = controllerNames.filter((c) => !testedControllers.includes(c));
|
|
5270
|
+
for (const name of result.controllers.missingTests) {
|
|
5271
|
+
result.missing.push({
|
|
5272
|
+
name,
|
|
5273
|
+
type: "controller",
|
|
5274
|
+
priority: determinePriority(name, "controller"),
|
|
5275
|
+
recommendation: `Create integration tests for ${name}Controller (HTTP status codes, authorization, validation)`,
|
|
5276
|
+
suggestedTestFile: `Tests/Integration/Controllers/${name}ControllerTests.cs`
|
|
5277
|
+
});
|
|
5278
|
+
}
|
|
5279
|
+
}
|
|
5280
|
+
function extractComponentNames(files, excludeSuffixes = []) {
|
|
5281
|
+
return files.map((f) => path11.basename(f, ".cs")).filter((name) => {
|
|
5282
|
+
const excludePatterns = [
|
|
5283
|
+
"Configuration",
|
|
5284
|
+
"Extensions",
|
|
5285
|
+
"Handler",
|
|
5286
|
+
"Specification",
|
|
5287
|
+
"Repository",
|
|
5288
|
+
"Service",
|
|
5289
|
+
"Controller",
|
|
5290
|
+
"Dto",
|
|
5291
|
+
"Validator",
|
|
5292
|
+
...excludeSuffixes
|
|
5293
|
+
];
|
|
5294
|
+
return !excludePatterns.some((pattern) => name.endsWith(pattern));
|
|
5295
|
+
}).filter((name) => !name.startsWith("I")).filter((name) => !name.startsWith("Base"));
|
|
5296
|
+
}
|
|
5297
|
+
function extractTestedNames(testFiles) {
|
|
5298
|
+
return testFiles.map((f) => {
|
|
5299
|
+
const basename = path11.basename(f, ".cs");
|
|
5300
|
+
return basename.replace(/Tests$/, "");
|
|
5301
|
+
});
|
|
5302
|
+
}
|
|
5303
|
+
function determinePriority(name, type) {
|
|
5304
|
+
const criticalPatterns = ["User", "Auth", "Payment", "Order", "Tenant", "Security", "Permission", "Role"];
|
|
5305
|
+
const highPatterns = ["Account", "Invoice", "Transaction", "Session", "Token", "Subscription"];
|
|
5306
|
+
const nameLower = name.toLowerCase();
|
|
5307
|
+
if (criticalPatterns.some((p) => nameLower.includes(p.toLowerCase()))) {
|
|
5308
|
+
return "critical";
|
|
5309
|
+
}
|
|
5310
|
+
if (highPatterns.some((p) => nameLower.includes(p.toLowerCase()))) {
|
|
5311
|
+
return "high";
|
|
5312
|
+
}
|
|
5313
|
+
if (type === "controller") {
|
|
5314
|
+
return "high";
|
|
5315
|
+
}
|
|
5316
|
+
if (type === "service") {
|
|
5317
|
+
return "medium";
|
|
5318
|
+
}
|
|
5319
|
+
return "medium";
|
|
5320
|
+
}
|
|
5321
|
+
function generateRecommendations(result) {
|
|
5322
|
+
if (result.summary.coverage < 50) {
|
|
5323
|
+
result.recommendations.push("CRITICAL: Overall test coverage is below 50%. Prioritize adding tests for critical components.");
|
|
5324
|
+
} else if (result.summary.coverage < 80) {
|
|
5325
|
+
result.recommendations.push("Test coverage is below recommended 80% threshold. Consider adding more tests.");
|
|
5326
|
+
}
|
|
5327
|
+
if (result.entities.coverage < 60) {
|
|
5328
|
+
result.recommendations.push(`Entity coverage (${result.entities.coverage}%) is low. Focus on testing factory methods, soft delete, and audit trails.`);
|
|
5329
|
+
}
|
|
5330
|
+
if (result.services.coverage < 70) {
|
|
5331
|
+
result.recommendations.push(`Service coverage (${result.services.coverage}%) is low. Ensure CRUD operations and business logic are tested.`);
|
|
5332
|
+
}
|
|
5333
|
+
if (result.controllers.coverage < 80) {
|
|
5334
|
+
result.recommendations.push(`Controller coverage (${result.controllers.coverage}%) is low. Add integration tests for HTTP endpoints.`);
|
|
5335
|
+
}
|
|
5336
|
+
const criticalMissing = result.missing.filter((m) => m.priority === "critical");
|
|
5337
|
+
if (criticalMissing.length > 0) {
|
|
5338
|
+
result.recommendations.push(`${criticalMissing.length} CRITICAL component(s) have no tests: ${criticalMissing.map((m) => m.name).join(", ")}`);
|
|
5339
|
+
}
|
|
5340
|
+
const securityComponents = result.missing.filter(
|
|
5341
|
+
(m) => m.name.toLowerCase().includes("auth") || m.name.toLowerCase().includes("security") || m.name.toLowerCase().includes("permission")
|
|
5342
|
+
);
|
|
5343
|
+
if (securityComponents.length > 0) {
|
|
5344
|
+
result.recommendations.push(`Security-sensitive components without tests: ${securityComponents.map((m) => m.name).join(", ")}. Add security tests immediately.`);
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
function formatCoverageResult(result, format) {
|
|
5348
|
+
if (format === "json") {
|
|
5349
|
+
return JSON.stringify(result, null, 2);
|
|
5350
|
+
}
|
|
5351
|
+
const lines = [];
|
|
5352
|
+
lines.push("# Test Coverage Analysis");
|
|
5353
|
+
lines.push("");
|
|
5354
|
+
const coverageEmoji = result.summary.coverage >= 80 ? "\u2705" : result.summary.coverage >= 50 ? "\u26A0\uFE0F" : "\u274C";
|
|
5355
|
+
lines.push("## Summary");
|
|
5356
|
+
lines.push("");
|
|
5357
|
+
lines.push(`| Metric | Value |`);
|
|
5358
|
+
lines.push(`|--------|-------|`);
|
|
5359
|
+
lines.push(`| **Overall Coverage** | ${coverageEmoji} ${result.summary.coverage}% (${result.summary.tested}/${result.summary.total}) |`);
|
|
5360
|
+
lines.push(`| Entities | ${formatCoverageCell(result.entities)} |`);
|
|
5361
|
+
lines.push(`| Services | ${formatCoverageCell(result.services)} |`);
|
|
5362
|
+
lines.push(`| Controllers | ${formatCoverageCell(result.controllers)} |`);
|
|
5363
|
+
lines.push("");
|
|
5364
|
+
if (format === "detailed") {
|
|
5365
|
+
if (result.entities.missingTests.length > 0) {
|
|
5366
|
+
lines.push("## Missing Entity Tests");
|
|
5367
|
+
lines.push("");
|
|
5368
|
+
for (const name of result.entities.missingTests) {
|
|
5369
|
+
lines.push(`- [ ] ${name}`);
|
|
5370
|
+
}
|
|
5371
|
+
lines.push("");
|
|
5372
|
+
}
|
|
5373
|
+
if (result.services.missingTests.length > 0) {
|
|
5374
|
+
lines.push("## Missing Service Tests");
|
|
5375
|
+
lines.push("");
|
|
5376
|
+
for (const name of result.services.missingTests) {
|
|
5377
|
+
lines.push(`- [ ] ${name}Service`);
|
|
5378
|
+
}
|
|
5379
|
+
lines.push("");
|
|
5380
|
+
}
|
|
5381
|
+
if (result.controllers.missingTests.length > 0) {
|
|
5382
|
+
lines.push("## Missing Controller Tests");
|
|
5383
|
+
lines.push("");
|
|
5384
|
+
for (const name of result.controllers.missingTests) {
|
|
5385
|
+
lines.push(`- [ ] ${name}Controller`);
|
|
5386
|
+
}
|
|
5387
|
+
lines.push("");
|
|
5388
|
+
}
|
|
5389
|
+
}
|
|
5390
|
+
if (result.missing.length > 0) {
|
|
5391
|
+
lines.push("## Missing Tests (By Priority)");
|
|
5392
|
+
lines.push("");
|
|
5393
|
+
const priorities = ["critical", "high", "medium", "low"];
|
|
5394
|
+
for (const priority of priorities) {
|
|
5395
|
+
const items = result.missing.filter((m) => m.priority === priority);
|
|
5396
|
+
if (items.length > 0) {
|
|
5397
|
+
const emoji = priority === "critical" ? "\u{1F534}" : priority === "high" ? "\u{1F7E0}" : priority === "medium" ? "\u{1F7E1}" : "\u{1F7E2}";
|
|
5398
|
+
lines.push(`### ${emoji} ${priority.charAt(0).toUpperCase() + priority.slice(1)} Priority`);
|
|
5399
|
+
lines.push("");
|
|
5400
|
+
lines.push("| Component | Type | Suggested Test File |");
|
|
5401
|
+
lines.push("|-----------|------|---------------------|");
|
|
5402
|
+
for (const item of items) {
|
|
5403
|
+
lines.push(`| ${item.name} | ${item.type} | \`${item.suggestedTestFile}\` |`);
|
|
5404
|
+
}
|
|
5405
|
+
lines.push("");
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
}
|
|
5409
|
+
if (result.recommendations.length > 0) {
|
|
5410
|
+
lines.push("## Recommendations");
|
|
5411
|
+
lines.push("");
|
|
5412
|
+
for (const rec of result.recommendations) {
|
|
5413
|
+
const emoji = rec.includes("CRITICAL") ? "\u{1F534}" : rec.includes("low") ? "\u26A0\uFE0F" : "\u{1F4A1}";
|
|
5414
|
+
lines.push(`${emoji} ${rec}`);
|
|
5415
|
+
lines.push("");
|
|
5416
|
+
}
|
|
5417
|
+
}
|
|
5418
|
+
lines.push("## Quick Actions");
|
|
5419
|
+
lines.push("");
|
|
5420
|
+
lines.push("Generate tests for missing components using:");
|
|
5421
|
+
lines.push("");
|
|
5422
|
+
if (result.missing.length > 0) {
|
|
5423
|
+
const firstMissing = result.missing[0];
|
|
5424
|
+
lines.push("```");
|
|
5425
|
+
lines.push(`scaffold_tests(target: "${firstMissing.type}", name: "${firstMissing.name}")`);
|
|
5426
|
+
lines.push("```");
|
|
5427
|
+
}
|
|
5428
|
+
lines.push("");
|
|
5429
|
+
return lines.join("\n");
|
|
5430
|
+
}
|
|
5431
|
+
function formatCoverageCell(category) {
|
|
5432
|
+
const emoji = category.coverage >= 80 ? "\u2705" : category.coverage >= 50 ? "\u26A0\uFE0F" : "\u274C";
|
|
5433
|
+
return `${emoji} ${category.coverage}% (${category.tested}/${category.total})`;
|
|
5434
|
+
}
|
|
5435
|
+
|
|
5436
|
+
// src/tools/validate-test-conventions.ts
|
|
5437
|
+
import path12 from "path";
|
|
5438
|
+
var validateTestConventionsTool = {
|
|
5439
|
+
name: "validate_test_conventions",
|
|
5440
|
+
description: "Validate that tests in a SmartStack project follow conventions: naming ({Method}_When{Condition}_Should{Result}), structure (Tests/Unit, Tests/Integration), patterns (AAA), assertions (FluentAssertions), and mocking (Moq).",
|
|
5441
|
+
inputSchema: {
|
|
5442
|
+
type: "object",
|
|
5443
|
+
properties: {
|
|
5444
|
+
path: {
|
|
5445
|
+
type: "string",
|
|
5446
|
+
description: "Project path to validate (default: configured SmartStack path)"
|
|
5447
|
+
},
|
|
5448
|
+
checks: {
|
|
5449
|
+
type: "array",
|
|
5450
|
+
items: {
|
|
5451
|
+
type: "string",
|
|
5452
|
+
enum: ["naming", "structure", "patterns", "assertions", "mocking", "all"]
|
|
5453
|
+
},
|
|
5454
|
+
default: ["all"],
|
|
5455
|
+
description: "Types of convention checks to perform"
|
|
5456
|
+
},
|
|
5457
|
+
autoFix: {
|
|
5458
|
+
type: "boolean",
|
|
5459
|
+
default: false,
|
|
5460
|
+
description: "Automatically fix minor issues (naming only)"
|
|
5461
|
+
}
|
|
5462
|
+
}
|
|
5463
|
+
}
|
|
5464
|
+
};
|
|
5465
|
+
var PATTERNS = {
|
|
5466
|
+
// Test method naming: MethodName_WhenCondition_ShouldExpectedResult
|
|
5467
|
+
testMethodNaming: /^(\w+)_When(\w+)_Should(\w+)$/,
|
|
5468
|
+
// Alternative valid patterns
|
|
5469
|
+
testMethodNamingAlt: /^(\w+)_Should(\w+)_When(\w+)$/,
|
|
5470
|
+
// Fact/Theory attributes
|
|
5471
|
+
factAttribute: /\[Fact\]/,
|
|
5472
|
+
theoryAttribute: /\[Theory\]/,
|
|
5473
|
+
// AAA pattern comments
|
|
5474
|
+
arrangeComment: /\/\/\s*Arrange/i,
|
|
5475
|
+
actComment: /\/\/\s*Act/i,
|
|
5476
|
+
assertComment: /\/\/\s*Assert/i,
|
|
5477
|
+
// FluentAssertions
|
|
5478
|
+
fluentAssertions: /\.Should\(\)/,
|
|
5479
|
+
// Moq
|
|
5480
|
+
moqUsage: /new Mock<|Mock<\w+>/,
|
|
5481
|
+
moqSetup: /\.Setup\(|\.Verify\(/,
|
|
5482
|
+
// Test class naming
|
|
5483
|
+
testClassNaming: /public class (\w+)Tests/,
|
|
5484
|
+
// xUnit patterns
|
|
5485
|
+
asyncTestMethod: /public async Task (\w+)/,
|
|
5486
|
+
syncTestMethod: /public void (\w+)/
|
|
5487
|
+
};
|
|
5488
|
+
async function handleValidateTestConventions(args, config) {
|
|
5489
|
+
const input = ValidateTestConventionsInputSchema.parse(args);
|
|
5490
|
+
const projectPath = input.path || config.smartstack.projectPath;
|
|
5491
|
+
const checks = input.checks.includes("all") ? ["naming", "structure", "patterns", "assertions", "mocking"] : input.checks;
|
|
5492
|
+
logger.info("Validating test conventions", { projectPath, checks });
|
|
5493
|
+
const structure = await findSmartStackStructure(projectPath);
|
|
5494
|
+
const result = {
|
|
5495
|
+
valid: true,
|
|
5496
|
+
violations: [],
|
|
5497
|
+
suggestions: [],
|
|
5498
|
+
autoFixedCount: 0
|
|
5499
|
+
};
|
|
5500
|
+
const testsPath = path12.join(structure.root, "Tests");
|
|
5501
|
+
try {
|
|
5502
|
+
let testFiles = [];
|
|
5503
|
+
try {
|
|
5504
|
+
testFiles = await findFiles("**/*Tests.cs", { cwd: testsPath });
|
|
5505
|
+
} catch {
|
|
5506
|
+
result.suggestions.push("No Tests directory found. Create a Tests project with Unit and Integration subdirectories.");
|
|
5507
|
+
return formatValidationResult(result);
|
|
5508
|
+
}
|
|
5509
|
+
if (testFiles.length === 0) {
|
|
5510
|
+
result.suggestions.push("No test files found. Create test files following the pattern {ComponentName}Tests.cs");
|
|
5511
|
+
return formatValidationResult(result);
|
|
5512
|
+
}
|
|
5513
|
+
if (checks.includes("structure")) {
|
|
5514
|
+
await validateStructure(testsPath, testFiles, result);
|
|
5515
|
+
}
|
|
5516
|
+
for (const testFile of testFiles) {
|
|
5517
|
+
const fullPath = path12.join(testsPath, testFile);
|
|
5518
|
+
const content = await readText(fullPath);
|
|
5519
|
+
if (checks.includes("naming")) {
|
|
5520
|
+
validateNaming(testFile, content, result, input.autoFix);
|
|
5521
|
+
}
|
|
5522
|
+
if (checks.includes("patterns")) {
|
|
5523
|
+
validatePatterns(testFile, content, result);
|
|
5524
|
+
}
|
|
5525
|
+
if (checks.includes("assertions")) {
|
|
5526
|
+
validateAssertions(testFile, content, result);
|
|
5527
|
+
}
|
|
5528
|
+
if (checks.includes("mocking")) {
|
|
5529
|
+
validateMocking(testFile, content, result);
|
|
5530
|
+
}
|
|
5531
|
+
}
|
|
5532
|
+
result.valid = result.violations.filter((v) => v.severity === "error").length === 0;
|
|
5533
|
+
} catch (error) {
|
|
5534
|
+
logger.error("Error validating test conventions", error);
|
|
5535
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
5536
|
+
}
|
|
5537
|
+
return formatValidationResult(result);
|
|
5538
|
+
}
|
|
5539
|
+
async function validateStructure(testsPath, testFiles, result) {
|
|
5540
|
+
const expectedDirs = ["Unit", "Integration"];
|
|
5541
|
+
const foundDirs = /* @__PURE__ */ new Set();
|
|
5542
|
+
for (const file of testFiles) {
|
|
5543
|
+
const parts = file.split(path12.sep);
|
|
5544
|
+
if (parts.length > 1) {
|
|
5545
|
+
foundDirs.add(parts[0]);
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
5548
|
+
for (const dir of expectedDirs) {
|
|
5549
|
+
if (!foundDirs.has(dir)) {
|
|
5550
|
+
result.violations.push({
|
|
5551
|
+
type: "structure",
|
|
5552
|
+
severity: "warning",
|
|
5553
|
+
file: testsPath,
|
|
5554
|
+
message: `Missing expected directory: Tests/${dir}`,
|
|
5555
|
+
suggestion: `Create Tests/${dir} directory for ${dir.toLowerCase()} tests`,
|
|
5556
|
+
autoFixable: false
|
|
5557
|
+
});
|
|
5558
|
+
}
|
|
5559
|
+
}
|
|
5560
|
+
const hasUnitDomain = testFiles.some((f) => f.includes(path12.join("Unit", "Domain")));
|
|
5561
|
+
const hasIntegrationControllers = testFiles.some((f) => f.includes(path12.join("Integration", "Controllers")));
|
|
5562
|
+
if (!hasUnitDomain && testFiles.some((f) => f.includes("Unit"))) {
|
|
5563
|
+
result.suggestions.push("Consider organizing unit tests into subdirectories: Unit/Domain, Unit/Services, Unit/Validators");
|
|
5564
|
+
}
|
|
5565
|
+
if (!hasIntegrationControllers && testFiles.some((f) => f.includes("Integration"))) {
|
|
5566
|
+
result.suggestions.push("Consider organizing integration tests into subdirectories: Integration/Controllers, Integration/Repositories");
|
|
5567
|
+
}
|
|
5568
|
+
const hasSecurityTests = testFiles.some((f) => f.includes("Security"));
|
|
5569
|
+
if (!hasSecurityTests) {
|
|
5570
|
+
result.suggestions.push("Consider adding a Tests/Security directory for security-focused tests");
|
|
5571
|
+
}
|
|
5572
|
+
}
|
|
5573
|
+
function validateNaming(testFile, content, result, _autoFix) {
|
|
5574
|
+
const classMatch = content.match(/public class (\w+)/);
|
|
5575
|
+
if (classMatch) {
|
|
5576
|
+
const className = classMatch[1];
|
|
5577
|
+
if (!className.endsWith("Tests")) {
|
|
5578
|
+
result.violations.push({
|
|
5579
|
+
type: "naming",
|
|
5580
|
+
severity: "error",
|
|
5581
|
+
file: testFile,
|
|
5582
|
+
message: `Test class '${className}' should end with 'Tests'`,
|
|
5583
|
+
suggestion: `Rename to '${className}Tests'`,
|
|
5584
|
+
autoFixable: true
|
|
5585
|
+
});
|
|
5586
|
+
}
|
|
5587
|
+
}
|
|
5588
|
+
const methodMatches = content.matchAll(/\[(Fact|Theory)\]\s*\n\s*public (?:async Task|void) (\w+)\(/g);
|
|
5589
|
+
for (const match of methodMatches) {
|
|
5590
|
+
const methodName = match[2];
|
|
5591
|
+
const isValidName = PATTERNS.testMethodNaming.test(methodName) || PATTERNS.testMethodNamingAlt.test(methodName);
|
|
5592
|
+
if (!isValidName) {
|
|
5593
|
+
if (methodName.includes("Test") && !methodName.includes("_")) {
|
|
5594
|
+
result.violations.push({
|
|
5595
|
+
type: "naming",
|
|
5596
|
+
severity: "warning",
|
|
5597
|
+
file: testFile,
|
|
5598
|
+
message: `Test method '${methodName}' doesn't follow naming convention`,
|
|
5599
|
+
suggestion: `Rename to format: {Method}_When{Condition}_Should{Result} (e.g., GetById_WhenExists_ShouldReturnEntity)`,
|
|
5600
|
+
autoFixable: false
|
|
5601
|
+
});
|
|
5602
|
+
} else if (!methodName.includes("_")) {
|
|
5603
|
+
result.violations.push({
|
|
5604
|
+
type: "naming",
|
|
5605
|
+
severity: "warning",
|
|
5606
|
+
file: testFile,
|
|
5607
|
+
message: `Test method '${methodName}' should use underscores to separate parts`,
|
|
5608
|
+
suggestion: `Use format: {Method}_When{Condition}_Should{Result}`,
|
|
5609
|
+
autoFixable: false
|
|
5610
|
+
});
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
5613
|
+
}
|
|
5614
|
+
}
|
|
5615
|
+
function validatePatterns(testFile, content, result) {
|
|
5616
|
+
const methodBlocks = content.matchAll(/\[(Fact|Theory)\][^\[]*?public (?:async Task|void) \w+\([^)]*\)\s*\{([^}]+)\}/gs);
|
|
5617
|
+
for (const match of methodBlocks) {
|
|
5618
|
+
const methodBody = match[2];
|
|
5619
|
+
const hasArrange = PATTERNS.arrangeComment.test(methodBody);
|
|
5620
|
+
const hasAct = PATTERNS.actComment.test(methodBody);
|
|
5621
|
+
const hasAssert = PATTERNS.assertComment.test(methodBody);
|
|
5622
|
+
if (!hasArrange && !hasAct && !hasAssert && methodBody.split("\n").length > 3) {
|
|
5623
|
+
result.violations.push({
|
|
5624
|
+
type: "pattern",
|
|
5625
|
+
severity: "warning",
|
|
5626
|
+
file: testFile,
|
|
5627
|
+
message: "Test method missing AAA (Arrange-Act-Assert) comments",
|
|
5628
|
+
suggestion: "Add // Arrange, // Act, // Assert comments to improve readability",
|
|
5629
|
+
autoFixable: false
|
|
5630
|
+
});
|
|
5631
|
+
break;
|
|
5632
|
+
}
|
|
5633
|
+
}
|
|
5634
|
+
const asyncMethods = content.matchAll(/public async Task (\w+)\([^)]*\)\s*\{([^}]+)\}/gs);
|
|
5635
|
+
for (const match of asyncMethods) {
|
|
5636
|
+
const methodName = match[1];
|
|
5637
|
+
const methodBody = match[2];
|
|
5638
|
+
if (!methodBody.includes("await")) {
|
|
5639
|
+
result.violations.push({
|
|
5640
|
+
type: "pattern",
|
|
5641
|
+
severity: "warning",
|
|
5642
|
+
file: testFile,
|
|
5643
|
+
message: `Async test method '${methodName}' doesn't contain 'await'`,
|
|
5644
|
+
suggestion: "Either add await or change to synchronous void method",
|
|
5645
|
+
autoFixable: false
|
|
5646
|
+
});
|
|
5647
|
+
}
|
|
5648
|
+
}
|
|
5649
|
+
}
|
|
5650
|
+
function validateAssertions(testFile, content, result) {
|
|
5651
|
+
const hasFluentAssertions = PATTERNS.fluentAssertions.test(content);
|
|
5652
|
+
const hasXunitAssert = /Assert\.\w+\(/.test(content);
|
|
5653
|
+
if (hasXunitAssert && !hasFluentAssertions) {
|
|
5654
|
+
result.violations.push({
|
|
5655
|
+
type: "assertion",
|
|
5656
|
+
severity: "warning",
|
|
5657
|
+
file: testFile,
|
|
5658
|
+
message: "Using xUnit Assert instead of FluentAssertions",
|
|
5659
|
+
suggestion: "Replace Assert.Equal(expected, actual) with actual.Should().Be(expected)",
|
|
5660
|
+
autoFixable: false
|
|
5661
|
+
});
|
|
5662
|
+
}
|
|
5663
|
+
if (hasFluentAssertions) {
|
|
5664
|
+
if (content.includes(".Should().NotBe(null)")) {
|
|
5665
|
+
result.violations.push({
|
|
5666
|
+
type: "assertion",
|
|
5667
|
+
severity: "warning",
|
|
5668
|
+
file: testFile,
|
|
5669
|
+
message: "Use .Should().NotBeNull() instead of .Should().NotBe(null)",
|
|
5670
|
+
suggestion: "Replace .Should().NotBe(null) with .Should().NotBeNull()",
|
|
5671
|
+
autoFixable: true
|
|
5672
|
+
});
|
|
5673
|
+
}
|
|
5674
|
+
if (content.includes(".Should().Throw<") && content.includes("async")) {
|
|
5675
|
+
if (!content.includes(".Should().ThrowAsync<")) {
|
|
5676
|
+
result.suggestions.push("Use .Should().ThrowAsync<T>() for async exception testing");
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
}
|
|
5680
|
+
const methodBlocks = content.matchAll(/\[(Fact|Theory)\][^\[]*?public (?:async Task|void) (\w+)\([^)]*\)\s*\{([^}]+)\}/gs);
|
|
5681
|
+
for (const match of methodBlocks) {
|
|
5682
|
+
const methodName = match[2];
|
|
5683
|
+
const methodBody = match[3];
|
|
5684
|
+
const hasAssertion = methodBody.includes(".Should()") || methodBody.includes("Assert.") || methodBody.includes("Verify(") || methodBody.includes("VerifyAll()");
|
|
5685
|
+
if (!hasAssertion) {
|
|
5686
|
+
result.violations.push({
|
|
5687
|
+
type: "assertion",
|
|
5688
|
+
severity: "error",
|
|
5689
|
+
file: testFile,
|
|
5690
|
+
message: `Test method '${methodName}' has no assertions`,
|
|
5691
|
+
suggestion: "Add at least one assertion to verify expected behavior",
|
|
5692
|
+
autoFixable: false
|
|
5693
|
+
});
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
}
|
|
5697
|
+
function validateMocking(testFile, content, result) {
|
|
5698
|
+
const hasMoq = PATTERNS.moqUsage.test(content);
|
|
5699
|
+
if (hasMoq) {
|
|
5700
|
+
if (content.includes("new Mock<") && !content.includes(".Setup(")) {
|
|
5701
|
+
result.violations.push({
|
|
5702
|
+
type: "mocking",
|
|
5703
|
+
severity: "warning",
|
|
5704
|
+
file: testFile,
|
|
5705
|
+
message: "Mock objects created but no Setup() calls found",
|
|
5706
|
+
suggestion: "Add .Setup() calls to define mock behavior",
|
|
5707
|
+
autoFixable: false
|
|
5708
|
+
});
|
|
5709
|
+
}
|
|
5710
|
+
const hasSetup = content.includes(".Setup(");
|
|
5711
|
+
const hasVerify = content.includes(".Verify(") || content.includes(".VerifyAll()");
|
|
5712
|
+
if (hasSetup && !hasVerify) {
|
|
5713
|
+
result.suggestions.push(`Consider adding .Verify() calls in ${testFile} to verify mock interactions`);
|
|
5714
|
+
}
|
|
5715
|
+
if (content.includes("MockBehavior.Strict")) {
|
|
5716
|
+
result.suggestions.push("Using MockBehavior.Strict - ensure all mock calls are set up");
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
5719
|
+
if (testFile.includes("Controller")) {
|
|
5720
|
+
if (!hasMoq && !content.includes("WebApplicationFactory")) {
|
|
5721
|
+
result.violations.push({
|
|
5722
|
+
type: "mocking",
|
|
5723
|
+
severity: "warning",
|
|
5724
|
+
file: testFile,
|
|
5725
|
+
message: "Controller tests should use mocking or WebApplicationFactory",
|
|
5726
|
+
suggestion: "Use Moq for unit tests or WebApplicationFactory for integration tests",
|
|
5727
|
+
autoFixable: false
|
|
5728
|
+
});
|
|
5729
|
+
}
|
|
5730
|
+
}
|
|
5731
|
+
}
|
|
5732
|
+
function formatValidationResult(result) {
|
|
5733
|
+
const lines = [];
|
|
5734
|
+
lines.push("# Test Convention Validation");
|
|
5735
|
+
lines.push("");
|
|
5736
|
+
const status = result.valid ? "\u2705 PASSED" : "\u274C FAILED";
|
|
5737
|
+
const errorCount = result.violations.filter((v) => v.severity === "error").length;
|
|
5738
|
+
const warningCount = result.violations.filter((v) => v.severity === "warning").length;
|
|
5739
|
+
lines.push("## Summary");
|
|
5740
|
+
lines.push("");
|
|
5741
|
+
lines.push(`| Status | ${status} |`);
|
|
5742
|
+
lines.push("|--------|----------|");
|
|
5743
|
+
lines.push(`| Errors | ${errorCount} |`);
|
|
5744
|
+
lines.push(`| Warnings | ${warningCount} |`);
|
|
5745
|
+
if (result.autoFixedCount > 0) {
|
|
5746
|
+
lines.push(`| Auto-fixed | ${result.autoFixedCount} |`);
|
|
5747
|
+
}
|
|
5748
|
+
lines.push("");
|
|
5749
|
+
if (result.violations.length > 0) {
|
|
5750
|
+
lines.push("## Violations");
|
|
5751
|
+
lines.push("");
|
|
5752
|
+
const byType = /* @__PURE__ */ new Map();
|
|
5753
|
+
for (const v of result.violations) {
|
|
5754
|
+
const existing = byType.get(v.type) || [];
|
|
5755
|
+
existing.push(v);
|
|
5756
|
+
byType.set(v.type, existing);
|
|
5757
|
+
}
|
|
5758
|
+
for (const [type, violations] of byType) {
|
|
5759
|
+
lines.push(`### ${type.charAt(0).toUpperCase() + type.slice(1)} Violations`);
|
|
5760
|
+
lines.push("");
|
|
5761
|
+
for (const v of violations) {
|
|
5762
|
+
const emoji = v.severity === "error" ? "\u274C" : "\u26A0\uFE0F";
|
|
5763
|
+
lines.push(`${emoji} **${v.file}**`);
|
|
5764
|
+
lines.push(` - ${v.message}`);
|
|
5765
|
+
lines.push(` - \u{1F4A1} ${v.suggestion}`);
|
|
5766
|
+
if (v.autoFixable) {
|
|
5767
|
+
lines.push(` - \u{1F527} Auto-fixable`);
|
|
5768
|
+
}
|
|
5769
|
+
lines.push("");
|
|
5770
|
+
}
|
|
5771
|
+
}
|
|
5772
|
+
}
|
|
5773
|
+
if (result.suggestions.length > 0) {
|
|
5774
|
+
lines.push("## Suggestions");
|
|
5775
|
+
lines.push("");
|
|
5776
|
+
for (const suggestion of result.suggestions) {
|
|
5777
|
+
lines.push(`\u{1F4A1} ${suggestion}`);
|
|
5778
|
+
lines.push("");
|
|
5779
|
+
}
|
|
5780
|
+
}
|
|
5781
|
+
lines.push("## SmartStack Test Conventions");
|
|
5782
|
+
lines.push("");
|
|
5783
|
+
lines.push("### Naming");
|
|
5784
|
+
lines.push("- Files: `{ComponentName}Tests.cs`");
|
|
5785
|
+
lines.push("- Classes: `{ComponentName}Tests`");
|
|
5786
|
+
lines.push("- Methods: `{MethodName}_When{Condition}_Should{ExpectedResult}`");
|
|
5787
|
+
lines.push("");
|
|
5788
|
+
lines.push("### Structure");
|
|
5789
|
+
lines.push("```");
|
|
5790
|
+
lines.push("Tests/");
|
|
5791
|
+
lines.push("\u251C\u2500\u2500 Unit/");
|
|
5792
|
+
lines.push("\u2502 \u251C\u2500\u2500 Domain/");
|
|
5793
|
+
lines.push("\u2502 \u251C\u2500\u2500 Services/");
|
|
5794
|
+
lines.push("\u2502 \u2514\u2500\u2500 Validators/");
|
|
5795
|
+
lines.push("\u251C\u2500\u2500 Integration/");
|
|
5796
|
+
lines.push("\u2502 \u251C\u2500\u2500 Controllers/");
|
|
5797
|
+
lines.push("\u2502 \u2514\u2500\u2500 Repositories/");
|
|
5798
|
+
lines.push("\u2514\u2500\u2500 Security/");
|
|
5799
|
+
lines.push("```");
|
|
5800
|
+
lines.push("");
|
|
5801
|
+
lines.push("### Pattern (AAA)");
|
|
5802
|
+
lines.push("```csharp");
|
|
5803
|
+
lines.push("[Fact]");
|
|
5804
|
+
lines.push("public async Task GetById_WhenExists_ShouldReturnEntity()");
|
|
5805
|
+
lines.push("{");
|
|
5806
|
+
lines.push(" // Arrange");
|
|
5807
|
+
lines.push(" var id = Guid.NewGuid();");
|
|
5808
|
+
lines.push("");
|
|
5809
|
+
lines.push(" // Act");
|
|
5810
|
+
lines.push(" var result = await _sut.GetByIdAsync(id);");
|
|
5811
|
+
lines.push("");
|
|
5812
|
+
lines.push(" // Assert");
|
|
5813
|
+
lines.push(" result.Should().NotBeNull();");
|
|
5814
|
+
lines.push("}");
|
|
5815
|
+
lines.push("```");
|
|
5816
|
+
lines.push("");
|
|
5817
|
+
return lines.join("\n");
|
|
5818
|
+
}
|
|
5819
|
+
|
|
5820
|
+
// src/tools/suggest-test-scenarios.ts
|
|
5821
|
+
import path13 from "path";
|
|
5822
|
+
var suggestTestScenariosTool = {
|
|
5823
|
+
name: "suggest_test_scenarios",
|
|
5824
|
+
description: "Analyze source code and suggest test scenarios based on detected methods, parameters, and patterns. Generates comprehensive test case recommendations for SmartStack components.",
|
|
5825
|
+
inputSchema: {
|
|
5826
|
+
type: "object",
|
|
5827
|
+
properties: {
|
|
5828
|
+
target: {
|
|
5829
|
+
type: "string",
|
|
5830
|
+
enum: ["entity", "service", "controller", "file"],
|
|
5831
|
+
description: "Type of target to analyze"
|
|
5832
|
+
},
|
|
5833
|
+
name: {
|
|
5834
|
+
type: "string",
|
|
5835
|
+
description: "Component name or file path"
|
|
5836
|
+
},
|
|
5837
|
+
depth: {
|
|
5838
|
+
type: "string",
|
|
5839
|
+
enum: ["basic", "comprehensive", "security-focused"],
|
|
5840
|
+
default: "comprehensive",
|
|
5841
|
+
description: "Depth of analysis"
|
|
5842
|
+
}
|
|
5843
|
+
},
|
|
5844
|
+
required: ["target", "name"]
|
|
5845
|
+
}
|
|
5846
|
+
};
|
|
5847
|
+
var PATTERNS2 = {
|
|
5848
|
+
// Method signatures
|
|
5849
|
+
publicMethod: /public\s+(?:async\s+)?(?:Task<)?(\w+)>?\s+(\w+)\s*\(([^)]*)\)/g,
|
|
5850
|
+
// Parameter extraction
|
|
5851
|
+
parameter: /(?:(\w+)\s+)?(\w+)\s+(\w+)(?:\s*=\s*([^,)]+))?/g,
|
|
5852
|
+
// Property
|
|
5853
|
+
property: /public\s+(?:virtual\s+)?(\w+)\??\s+(\w+)\s*\{\s*get;/g,
|
|
5854
|
+
// Validation attributes
|
|
5855
|
+
validationAttribute: /\[(Required|MaxLength|MinLength|Range|EmailAddress|Phone|Url|RegularExpression)\s*(?:\(([^)]*)\))?\]/g,
|
|
5856
|
+
// Entity patterns
|
|
5857
|
+
baseEntity: /:\s*(?:BaseEntity|SystemEntity)/,
|
|
5858
|
+
tenantEntity: /ITenantEntity/,
|
|
5859
|
+
// Factory method
|
|
5860
|
+
factoryMethod: /public\s+static\s+\w+\s+Create\s*\(/,
|
|
5861
|
+
// Soft delete
|
|
5862
|
+
softDelete: /public\s+void\s+SoftDelete/
|
|
5863
|
+
};
|
|
5864
|
+
async function handleSuggestTestScenarios(args, config) {
|
|
5865
|
+
const input = SuggestTestScenariosInputSchema.parse(args);
|
|
5866
|
+
logger.info("Suggesting test scenarios", { target: input.target, name: input.name, depth: input.depth });
|
|
5867
|
+
const structure = await findSmartStackStructure(config.smartstack.projectPath);
|
|
5868
|
+
const result = {
|
|
5869
|
+
target: input.name,
|
|
5870
|
+
targetType: input.target,
|
|
5871
|
+
detectedMethods: [],
|
|
5872
|
+
scenarios: [],
|
|
5873
|
+
coverage: {
|
|
5874
|
+
happyPath: 0,
|
|
5875
|
+
validation: 0,
|
|
5876
|
+
errorHandling: 0,
|
|
5877
|
+
security: 0,
|
|
5878
|
+
edgeCases: 0
|
|
5879
|
+
}
|
|
5880
|
+
};
|
|
5881
|
+
try {
|
|
5882
|
+
const sourceFile = await findSourceFile(input.target, input.name, structure, config);
|
|
5883
|
+
if (!sourceFile) {
|
|
5884
|
+
return `Error: Could not find ${input.target} '${input.name}'`;
|
|
5885
|
+
}
|
|
5886
|
+
const content = await readText(sourceFile);
|
|
5887
|
+
result.detectedMethods = parseSourceCode(content);
|
|
5888
|
+
result.scenarios = generateScenarios(
|
|
5889
|
+
result.detectedMethods,
|
|
5890
|
+
content,
|
|
5891
|
+
input.target,
|
|
5892
|
+
input.depth
|
|
5893
|
+
);
|
|
5894
|
+
calculateCoverageDistribution(result);
|
|
5895
|
+
} catch (error) {
|
|
5896
|
+
logger.error("Error suggesting test scenarios", error);
|
|
5897
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
5898
|
+
}
|
|
5899
|
+
return formatScenariosResult(result, input.depth);
|
|
5900
|
+
}
|
|
5901
|
+
async function findSourceFile(target, name, structure, _config) {
|
|
5902
|
+
let searchPath;
|
|
5903
|
+
let pattern;
|
|
5904
|
+
switch (target) {
|
|
5905
|
+
case "entity":
|
|
5906
|
+
searchPath = structure.domain || structure.root;
|
|
5907
|
+
pattern = `**/${name}.cs`;
|
|
5908
|
+
break;
|
|
5909
|
+
case "service":
|
|
5910
|
+
searchPath = structure.application || structure.root;
|
|
5911
|
+
pattern = `**/${name}Service.cs`;
|
|
5912
|
+
break;
|
|
5913
|
+
case "controller":
|
|
5914
|
+
searchPath = structure.api || structure.root;
|
|
5915
|
+
pattern = `**/${name}Controller.cs`;
|
|
5916
|
+
break;
|
|
5917
|
+
case "file":
|
|
5918
|
+
return path13.isAbsolute(name) ? name : path13.join(structure.root, name);
|
|
5919
|
+
default:
|
|
5920
|
+
return null;
|
|
5921
|
+
}
|
|
5922
|
+
const files = await findFiles(pattern, { cwd: searchPath });
|
|
5923
|
+
if (files.length === 0) {
|
|
5924
|
+
const altPattern = `**/*${name}*.cs`;
|
|
5925
|
+
const altFiles = await findFiles(altPattern, { cwd: searchPath });
|
|
5926
|
+
if (altFiles.length > 0) {
|
|
5927
|
+
return path13.join(searchPath, altFiles[0]);
|
|
5928
|
+
}
|
|
5929
|
+
return null;
|
|
5930
|
+
}
|
|
5931
|
+
return path13.join(searchPath, files[0]);
|
|
5932
|
+
}
|
|
5933
|
+
function parseSourceCode(content) {
|
|
5934
|
+
const methods = [];
|
|
5935
|
+
PATTERNS2.publicMethod.lastIndex = 0;
|
|
5936
|
+
let match;
|
|
5937
|
+
while ((match = PATTERNS2.publicMethod.exec(content)) !== null) {
|
|
5938
|
+
const returnType = match[1];
|
|
5939
|
+
const methodName = match[2];
|
|
5940
|
+
const parametersStr = match[3];
|
|
5941
|
+
const parameters = [];
|
|
5942
|
+
if (parametersStr.trim()) {
|
|
5943
|
+
const params = parametersStr.split(",");
|
|
5944
|
+
for (const param of params) {
|
|
5945
|
+
const trimmed = param.trim();
|
|
5946
|
+
if (trimmed) {
|
|
5947
|
+
const parts = trimmed.split(/\s+/);
|
|
5948
|
+
if (parts.length >= 2) {
|
|
5949
|
+
parameters.push(`${parts[parts.length - 2]} ${parts[parts.length - 1]}`);
|
|
5950
|
+
}
|
|
5951
|
+
}
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
5954
|
+
const isAsync = content.substring(Math.max(0, match.index - 20), match.index).includes("async");
|
|
5955
|
+
const isPublic = content.substring(Math.max(0, match.index - 20), match.index).includes("public");
|
|
5956
|
+
if (isPublic && !methodName.startsWith("get_") && !methodName.startsWith("set_")) {
|
|
5957
|
+
methods.push({
|
|
5958
|
+
name: methodName,
|
|
5959
|
+
returnType,
|
|
5960
|
+
parameters,
|
|
5961
|
+
isAsync,
|
|
5962
|
+
visibility: "public"
|
|
5963
|
+
});
|
|
5964
|
+
}
|
|
5965
|
+
}
|
|
5966
|
+
return methods;
|
|
5967
|
+
}
|
|
5968
|
+
function generateScenarios(methods, content, target, depth) {
|
|
5969
|
+
const scenarios = [];
|
|
5970
|
+
const isEntity = PATTERNS2.baseEntity.test(content);
|
|
5971
|
+
const hasTenant = PATTERNS2.tenantEntity.test(content);
|
|
5972
|
+
const hasFactoryMethod = PATTERNS2.factoryMethod.test(content);
|
|
5973
|
+
const hasSoftDelete = PATTERNS2.softDelete.test(content);
|
|
5974
|
+
for (const method of methods) {
|
|
5975
|
+
const methodScenarios = generateMethodScenarios(
|
|
5976
|
+
method,
|
|
5977
|
+
{ isEntity, hasTenant, hasFactoryMethod, hasSoftDelete },
|
|
5978
|
+
target,
|
|
5979
|
+
depth
|
|
5980
|
+
);
|
|
5981
|
+
scenarios.push(...methodScenarios);
|
|
5982
|
+
}
|
|
5983
|
+
if (isEntity && target === "entity") {
|
|
5984
|
+
scenarios.push(...generateEntityScenarios(content, hasTenant, hasSoftDelete, depth));
|
|
5985
|
+
}
|
|
5986
|
+
if (depth !== "basic") {
|
|
5987
|
+
scenarios.push(...generateSecurityScenarios(methods, hasTenant, target));
|
|
5988
|
+
}
|
|
5989
|
+
return scenarios;
|
|
5990
|
+
}
|
|
5991
|
+
function generateMethodScenarios(method, patterns, _target, depth) {
|
|
5992
|
+
const scenarios = [];
|
|
5993
|
+
const methodLower = method.name.toLowerCase();
|
|
5994
|
+
scenarios.push({
|
|
5995
|
+
methodName: method.name,
|
|
5996
|
+
scenarioName: "Happy Path",
|
|
5997
|
+
description: `Test ${method.name} with valid data`,
|
|
5998
|
+
type: "happy-path",
|
|
5999
|
+
priority: "critical",
|
|
6000
|
+
testMethodName: `${method.name}_WhenValidData_ShouldSucceed`,
|
|
6001
|
+
assertions: generateAssertions(method, "happy-path")
|
|
6002
|
+
});
|
|
6003
|
+
if (methodLower.includes("getbyid") || methodLower.includes("get")) {
|
|
6004
|
+
scenarios.push({
|
|
6005
|
+
methodName: method.name,
|
|
6006
|
+
scenarioName: "Not Found",
|
|
6007
|
+
description: `Test ${method.name} when entity does not exist`,
|
|
6008
|
+
type: "error-handling",
|
|
6009
|
+
priority: "high",
|
|
6010
|
+
testMethodName: `${method.name}_WhenNotExists_ShouldReturnNull`,
|
|
6011
|
+
assertions: ["result.Should().BeNull()"]
|
|
6012
|
+
});
|
|
6013
|
+
}
|
|
6014
|
+
if (methodLower.includes("create") || methodLower.includes("add")) {
|
|
6015
|
+
scenarios.push({
|
|
6016
|
+
methodName: method.name,
|
|
6017
|
+
scenarioName: "Validation Failure",
|
|
6018
|
+
description: `Test ${method.name} with invalid data`,
|
|
6019
|
+
type: "validation",
|
|
6020
|
+
priority: "high",
|
|
6021
|
+
testMethodName: `${method.name}_WhenInvalidData_ShouldThrow`,
|
|
6022
|
+
assertions: ["act.Should().ThrowAsync<ValidationException>()"]
|
|
6023
|
+
});
|
|
6024
|
+
if (patterns.hasTenant) {
|
|
6025
|
+
scenarios.push({
|
|
6026
|
+
methodName: method.name,
|
|
6027
|
+
scenarioName: "Missing Tenant",
|
|
6028
|
+
description: `Test ${method.name} without tenant context`,
|
|
6029
|
+
type: "security",
|
|
6030
|
+
priority: "critical",
|
|
6031
|
+
testMethodName: `${method.name}_WhenNoTenant_ShouldThrow`,
|
|
6032
|
+
assertions: ["act.Should().ThrowAsync<ArgumentException>()"]
|
|
6033
|
+
});
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
if (methodLower.includes("update")) {
|
|
6037
|
+
scenarios.push({
|
|
6038
|
+
methodName: method.name,
|
|
6039
|
+
scenarioName: "Not Found",
|
|
6040
|
+
description: `Test ${method.name} when entity does not exist`,
|
|
6041
|
+
type: "error-handling",
|
|
6042
|
+
priority: "high",
|
|
6043
|
+
testMethodName: `${method.name}_WhenNotExists_ShouldThrow`,
|
|
6044
|
+
assertions: ["act.Should().ThrowAsync<InvalidOperationException>()"]
|
|
6045
|
+
});
|
|
6046
|
+
if (depth !== "basic") {
|
|
6047
|
+
scenarios.push({
|
|
6048
|
+
methodName: method.name,
|
|
6049
|
+
scenarioName: "Concurrent Update",
|
|
6050
|
+
description: `Test ${method.name} with concurrent modification`,
|
|
6051
|
+
type: "edge-case",
|
|
6052
|
+
priority: "medium",
|
|
6053
|
+
testMethodName: `${method.name}_WhenConcurrentUpdate_ShouldHandleConflict`,
|
|
6054
|
+
assertions: ["Should handle DbUpdateConcurrencyException"]
|
|
6055
|
+
});
|
|
6056
|
+
}
|
|
6057
|
+
}
|
|
6058
|
+
if (methodLower.includes("delete")) {
|
|
6059
|
+
scenarios.push({
|
|
6060
|
+
methodName: method.name,
|
|
6061
|
+
scenarioName: "Not Found",
|
|
6062
|
+
description: `Test ${method.name} when entity does not exist`,
|
|
6063
|
+
type: "error-handling",
|
|
6064
|
+
priority: "high",
|
|
6065
|
+
testMethodName: `${method.name}_WhenNotExists_ShouldReturnFalse`,
|
|
6066
|
+
assertions: ["result.Should().BeFalse()"]
|
|
6067
|
+
});
|
|
6068
|
+
if (patterns.hasSoftDelete) {
|
|
6069
|
+
scenarios.push({
|
|
6070
|
+
methodName: method.name,
|
|
6071
|
+
scenarioName: "Soft Delete",
|
|
6072
|
+
description: `Verify ${method.name} performs soft delete`,
|
|
6073
|
+
type: "happy-path",
|
|
6074
|
+
priority: "high",
|
|
6075
|
+
testMethodName: `${method.name}_WhenCalled_ShouldSoftDelete`,
|
|
6076
|
+
assertions: ["entity.IsDeleted.Should().BeTrue()", "entity.DeletedAt.Should().NotBeNull()"]
|
|
6077
|
+
});
|
|
6078
|
+
}
|
|
6079
|
+
}
|
|
6080
|
+
if (method.isAsync && depth !== "basic") {
|
|
6081
|
+
scenarios.push({
|
|
6082
|
+
methodName: method.name,
|
|
6083
|
+
scenarioName: "Cancellation",
|
|
6084
|
+
description: `Test ${method.name} with cancellation token`,
|
|
6085
|
+
type: "edge-case",
|
|
6086
|
+
priority: "low",
|
|
6087
|
+
testMethodName: `${method.name}_WhenCancelled_ShouldThrowOperationCancelled`,
|
|
6088
|
+
assertions: ["act.Should().ThrowAsync<OperationCanceledException>()"]
|
|
6089
|
+
});
|
|
6090
|
+
}
|
|
6091
|
+
return scenarios;
|
|
6092
|
+
}
|
|
6093
|
+
function generateEntityScenarios(content, hasTenant, hasSoftDelete, depth) {
|
|
6094
|
+
const scenarios = [];
|
|
6095
|
+
if (PATTERNS2.factoryMethod.test(content)) {
|
|
6096
|
+
scenarios.push({
|
|
6097
|
+
methodName: "Create",
|
|
6098
|
+
scenarioName: "Factory Method",
|
|
6099
|
+
description: "Test entity creation via factory method",
|
|
6100
|
+
type: "happy-path",
|
|
6101
|
+
priority: "critical",
|
|
6102
|
+
testMethodName: "Create_WhenValidData_ShouldCreateEntity",
|
|
6103
|
+
assertions: [
|
|
6104
|
+
"entity.Should().NotBeNull()",
|
|
6105
|
+
"entity.Id.Should().NotBeEmpty()",
|
|
6106
|
+
"entity.CreatedAt.Should().BeCloseTo(DateTime.UtcNow)"
|
|
6107
|
+
]
|
|
6108
|
+
});
|
|
6109
|
+
if (hasTenant) {
|
|
6110
|
+
scenarios.push({
|
|
6111
|
+
methodName: "Create",
|
|
6112
|
+
scenarioName: "Invalid Tenant",
|
|
6113
|
+
description: "Test entity creation with empty tenant ID",
|
|
6114
|
+
type: "validation",
|
|
6115
|
+
priority: "critical",
|
|
6116
|
+
testMethodName: "Create_WhenEmptyTenantId_ShouldThrow",
|
|
6117
|
+
assertions: ["act.Should().Throw<ArgumentException>()"]
|
|
6118
|
+
});
|
|
6119
|
+
}
|
|
6120
|
+
}
|
|
6121
|
+
if (hasSoftDelete) {
|
|
6122
|
+
scenarios.push({
|
|
6123
|
+
methodName: "SoftDelete",
|
|
6124
|
+
scenarioName: "Soft Delete",
|
|
6125
|
+
description: "Test soft delete sets IsDeleted and DeletedAt",
|
|
6126
|
+
type: "happy-path",
|
|
6127
|
+
priority: "high",
|
|
6128
|
+
testMethodName: "SoftDelete_WhenCalled_ShouldSetDeletedFields",
|
|
6129
|
+
assertions: [
|
|
6130
|
+
"entity.IsDeleted.Should().BeTrue()",
|
|
6131
|
+
"entity.DeletedAt.Should().NotBeNull()",
|
|
6132
|
+
"entity.DeletedBy.Should().Be(deletedBy)"
|
|
6133
|
+
]
|
|
6134
|
+
});
|
|
6135
|
+
if (depth !== "basic") {
|
|
6136
|
+
scenarios.push({
|
|
6137
|
+
methodName: "Restore",
|
|
6138
|
+
scenarioName: "Restore After Delete",
|
|
6139
|
+
description: "Test restoring a soft-deleted entity",
|
|
6140
|
+
type: "happy-path",
|
|
6141
|
+
priority: "medium",
|
|
6142
|
+
testMethodName: "Restore_WhenSoftDeleted_ShouldClearDeletedFields",
|
|
6143
|
+
assertions: [
|
|
6144
|
+
"entity.IsDeleted.Should().BeFalse()",
|
|
6145
|
+
"entity.DeletedAt.Should().BeNull()"
|
|
6146
|
+
]
|
|
6147
|
+
});
|
|
6148
|
+
}
|
|
6149
|
+
}
|
|
6150
|
+
if (depth !== "basic") {
|
|
6151
|
+
scenarios.push({
|
|
6152
|
+
methodName: "Update",
|
|
6153
|
+
scenarioName: "Audit Trail",
|
|
6154
|
+
description: "Test that Update sets audit fields",
|
|
6155
|
+
type: "happy-path",
|
|
6156
|
+
priority: "medium",
|
|
6157
|
+
testMethodName: "Update_WhenCalled_ShouldSetAuditFields",
|
|
6158
|
+
assertions: [
|
|
6159
|
+
"entity.UpdatedAt.Should().NotBeNull()",
|
|
6160
|
+
"entity.UpdatedBy.Should().Be(updatedBy)",
|
|
6161
|
+
"entity.CreatedAt.Should().Be(originalCreatedAt)"
|
|
6162
|
+
]
|
|
6163
|
+
});
|
|
6164
|
+
}
|
|
6165
|
+
return scenarios;
|
|
6166
|
+
}
|
|
6167
|
+
function generateSecurityScenarios(_methods, hasTenant, _target) {
|
|
6168
|
+
const scenarios = [];
|
|
6169
|
+
if (hasTenant) {
|
|
6170
|
+
scenarios.push({
|
|
6171
|
+
methodName: "TenantIsolation",
|
|
6172
|
+
scenarioName: "Cross-Tenant Access",
|
|
6173
|
+
description: "Test that entities from other tenants are not accessible",
|
|
6174
|
+
type: "security",
|
|
6175
|
+
priority: "critical",
|
|
6176
|
+
testMethodName: "GetById_WhenDifferentTenant_ShouldReturnNull",
|
|
6177
|
+
assertions: ["result.Should().BeNull()"]
|
|
6178
|
+
});
|
|
6179
|
+
}
|
|
6180
|
+
if (_target === "controller") {
|
|
6181
|
+
scenarios.push({
|
|
6182
|
+
methodName: "Authorization",
|
|
6183
|
+
scenarioName: "Unauthorized Access",
|
|
6184
|
+
description: "Test that unauthorized requests are rejected",
|
|
6185
|
+
type: "security",
|
|
6186
|
+
priority: "critical",
|
|
6187
|
+
testMethodName: "GetAll_WhenUnauthorized_ShouldReturn401",
|
|
6188
|
+
assertions: ["response.StatusCode.Should().Be(HttpStatusCode.Unauthorized)"]
|
|
6189
|
+
});
|
|
6190
|
+
scenarios.push({
|
|
6191
|
+
methodName: "InputValidation",
|
|
6192
|
+
scenarioName: "SQL Injection",
|
|
6193
|
+
description: "Test that SQL injection attempts are handled",
|
|
6194
|
+
type: "security",
|
|
6195
|
+
priority: "high",
|
|
6196
|
+
testMethodName: "Create_WhenSqlInjectionAttempt_ShouldSanitize",
|
|
6197
|
+
assertions: ["Should not execute malicious SQL"]
|
|
6198
|
+
});
|
|
6199
|
+
scenarios.push({
|
|
6200
|
+
methodName: "InputValidation",
|
|
6201
|
+
scenarioName: "XSS Prevention",
|
|
6202
|
+
description: "Test that XSS attempts are handled",
|
|
6203
|
+
type: "security",
|
|
6204
|
+
priority: "high",
|
|
6205
|
+
testMethodName: "Create_WhenXssAttempt_ShouldSanitize",
|
|
6206
|
+
assertions: ["Should not store or return script tags"]
|
|
6207
|
+
});
|
|
6208
|
+
}
|
|
6209
|
+
return scenarios;
|
|
6210
|
+
}
|
|
6211
|
+
function generateAssertions(method, type) {
|
|
6212
|
+
const assertions = [];
|
|
6213
|
+
if (type === "happy-path") {
|
|
6214
|
+
if (method.returnType === "void" || method.returnType === "Task") {
|
|
6215
|
+
assertions.push("Should complete without exception");
|
|
6216
|
+
} else if (method.returnType.includes("bool") || method.returnType === "Boolean") {
|
|
6217
|
+
assertions.push("result.Should().BeTrue()");
|
|
6218
|
+
} else {
|
|
6219
|
+
assertions.push("result.Should().NotBeNull()");
|
|
6220
|
+
}
|
|
6221
|
+
}
|
|
6222
|
+
return assertions;
|
|
6223
|
+
}
|
|
6224
|
+
function calculateCoverageDistribution(result) {
|
|
6225
|
+
const total = result.scenarios.length;
|
|
6226
|
+
if (total === 0) return;
|
|
6227
|
+
const counts = {
|
|
6228
|
+
happyPath: result.scenarios.filter((s) => s.type === "happy-path").length,
|
|
6229
|
+
validation: result.scenarios.filter((s) => s.type === "validation").length,
|
|
6230
|
+
errorHandling: result.scenarios.filter((s) => s.type === "error-handling").length,
|
|
6231
|
+
security: result.scenarios.filter((s) => s.type === "security").length,
|
|
6232
|
+
edgeCases: result.scenarios.filter((s) => s.type === "edge-case" || s.type === "performance").length
|
|
6233
|
+
};
|
|
6234
|
+
result.coverage = {
|
|
6235
|
+
happyPath: Math.round(counts.happyPath / total * 100),
|
|
6236
|
+
validation: Math.round(counts.validation / total * 100),
|
|
6237
|
+
errorHandling: Math.round(counts.errorHandling / total * 100),
|
|
6238
|
+
security: Math.round(counts.security / total * 100),
|
|
6239
|
+
edgeCases: Math.round(counts.edgeCases / total * 100)
|
|
6240
|
+
};
|
|
6241
|
+
}
|
|
6242
|
+
function formatScenariosResult(result, depth) {
|
|
6243
|
+
const lines = [];
|
|
6244
|
+
lines.push(`# Test Scenarios for ${result.target}`);
|
|
6245
|
+
lines.push("");
|
|
6246
|
+
lines.push(`> Analysis depth: **${depth}**`);
|
|
6247
|
+
lines.push("");
|
|
6248
|
+
if (result.detectedMethods.length > 0) {
|
|
6249
|
+
lines.push("## Detected Methods");
|
|
6250
|
+
lines.push("");
|
|
6251
|
+
lines.push("| Method | Return Type | Parameters | Async |");
|
|
6252
|
+
lines.push("|--------|-------------|------------|-------|");
|
|
6253
|
+
for (const method of result.detectedMethods) {
|
|
6254
|
+
const params = method.parameters.length > 0 ? method.parameters.join(", ") : "none";
|
|
6255
|
+
lines.push(`| ${method.name} | ${method.returnType} | ${params} | ${method.isAsync ? "Yes" : "No"} |`);
|
|
6256
|
+
}
|
|
6257
|
+
lines.push("");
|
|
6258
|
+
}
|
|
6259
|
+
lines.push("## Scenario Coverage Distribution");
|
|
6260
|
+
lines.push("");
|
|
6261
|
+
lines.push("```");
|
|
6262
|
+
lines.push(`Happy Path: ${"\u2588".repeat(Math.floor(result.coverage.happyPath / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.happyPath / 5))} ${result.coverage.happyPath}%`);
|
|
6263
|
+
lines.push(`Validation: ${"\u2588".repeat(Math.floor(result.coverage.validation / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.validation / 5))} ${result.coverage.validation}%`);
|
|
6264
|
+
lines.push(`Error Handling: ${"\u2588".repeat(Math.floor(result.coverage.errorHandling / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.errorHandling / 5))} ${result.coverage.errorHandling}%`);
|
|
6265
|
+
lines.push(`Security: ${"\u2588".repeat(Math.floor(result.coverage.security / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.security / 5))} ${result.coverage.security}%`);
|
|
6266
|
+
lines.push(`Edge Cases: ${"\u2588".repeat(Math.floor(result.coverage.edgeCases / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.edgeCases / 5))} ${result.coverage.edgeCases}%`);
|
|
6267
|
+
lines.push("```");
|
|
6268
|
+
lines.push("");
|
|
6269
|
+
lines.push("## Suggested Test Scenarios");
|
|
6270
|
+
lines.push("");
|
|
6271
|
+
const priorities = ["critical", "high", "medium", "low"];
|
|
6272
|
+
for (const priority of priorities) {
|
|
6273
|
+
const scenarios = result.scenarios.filter((s) => s.priority === priority);
|
|
6274
|
+
if (scenarios.length === 0) continue;
|
|
6275
|
+
const emoji = priority === "critical" ? "\u{1F534}" : priority === "high" ? "\u{1F7E0}" : priority === "medium" ? "\u{1F7E1}" : "\u{1F7E2}";
|
|
6276
|
+
lines.push(`### ${emoji} ${priority.charAt(0).toUpperCase() + priority.slice(1)} Priority`);
|
|
6277
|
+
lines.push("");
|
|
6278
|
+
for (const scenario of scenarios) {
|
|
6279
|
+
const typeEmoji = getTypeEmoji(scenario.type);
|
|
6280
|
+
lines.push(`#### ${typeEmoji} ${scenario.methodName} - ${scenario.scenarioName}`);
|
|
6281
|
+
lines.push("");
|
|
6282
|
+
lines.push(`**Description:** ${scenario.description}`);
|
|
6283
|
+
lines.push("");
|
|
6284
|
+
lines.push(`**Test Method:** \`${scenario.testMethodName}\``);
|
|
6285
|
+
lines.push("");
|
|
6286
|
+
if (scenario.assertions.length > 0) {
|
|
6287
|
+
lines.push("**Assertions:**");
|
|
6288
|
+
for (const assertion of scenario.assertions) {
|
|
6289
|
+
lines.push(`- \`${assertion}\``);
|
|
6290
|
+
}
|
|
6291
|
+
lines.push("");
|
|
6292
|
+
}
|
|
6293
|
+
}
|
|
6294
|
+
}
|
|
6295
|
+
lines.push("## Quick Test Generation");
|
|
6296
|
+
lines.push("");
|
|
6297
|
+
lines.push("Generate tests for this component:");
|
|
6298
|
+
lines.push("");
|
|
6299
|
+
lines.push("```");
|
|
6300
|
+
lines.push(`scaffold_tests(target: "${result.targetType}", name: "${result.target}", testTypes: ["unit", "security"])`);
|
|
6301
|
+
lines.push("```");
|
|
6302
|
+
lines.push("");
|
|
6303
|
+
return lines.join("\n");
|
|
6304
|
+
}
|
|
6305
|
+
function getTypeEmoji(type) {
|
|
6306
|
+
switch (type) {
|
|
6307
|
+
case "happy-path":
|
|
6308
|
+
return "\u2705";
|
|
6309
|
+
case "validation":
|
|
6310
|
+
return "\u{1F50D}";
|
|
6311
|
+
case "error-handling":
|
|
6312
|
+
return "\u26A0\uFE0F";
|
|
6313
|
+
case "security":
|
|
6314
|
+
return "\u{1F512}";
|
|
6315
|
+
case "edge-case":
|
|
6316
|
+
return "\u{1F9EA}";
|
|
6317
|
+
case "performance":
|
|
6318
|
+
return "\u26A1";
|
|
6319
|
+
default:
|
|
6320
|
+
return "\u{1F4CB}";
|
|
6321
|
+
}
|
|
6322
|
+
}
|
|
6323
|
+
|
|
6324
|
+
// src/resources/conventions.ts
|
|
6325
|
+
var conventionsResourceTemplate = {
|
|
6326
|
+
uri: "smartstack://conventions",
|
|
6327
|
+
name: "AtlasHub Conventions",
|
|
6328
|
+
description: "Documentation of AtlasHub/SmartStack naming conventions, patterns, and best practices",
|
|
6329
|
+
mimeType: "text/markdown"
|
|
6330
|
+
};
|
|
6331
|
+
async function getConventionsResource(config) {
|
|
6332
|
+
const { schemas, tablePrefixes, codePrefixes, migrationFormat, namespaces, servicePattern } = config.conventions;
|
|
6333
|
+
return `# AtlasHub SmartStack Conventions
|
|
6334
|
+
|
|
6335
|
+
## Overview
|
|
6336
|
+
|
|
6337
|
+
This document describes the mandatory conventions for extending the SmartStack/AtlasHub platform.
|
|
6338
|
+
Following these conventions ensures compatibility and prevents conflicts.
|
|
6339
|
+
|
|
6340
|
+
---
|
|
6341
|
+
|
|
6342
|
+
## 1. Database Conventions
|
|
6343
|
+
|
|
6344
|
+
### SQL Schemas
|
|
6345
|
+
|
|
6346
|
+
SmartStack uses SQL Server schemas to separate platform tables from client extensions:
|
|
6347
|
+
|
|
6348
|
+
| Schema | Usage | Description |
|
|
6349
|
+
|--------|-------|-------------|
|
|
6350
|
+
| \`${schemas.platform}\` | SmartStack platform | All native SmartStack tables |
|
|
6351
|
+
| \`${schemas.extensions}\` | Client extensions | Custom tables added by clients |
|
|
6352
|
+
|
|
6353
|
+
### Domain Table Prefixes
|
|
6354
|
+
|
|
6355
|
+
Tables are organized by domain using prefixes:
|
|
6356
|
+
|
|
6357
|
+
| Prefix | Domain | Example Tables |
|
|
6358
|
+
|--------|--------|----------------|
|
|
6359
|
+
| \`auth_\` | Authorization | auth_Users, auth_Roles, auth_Permissions |
|
|
6360
|
+
| \`nav_\` | Navigation | nav_Contexts, nav_Applications, nav_Modules |
|
|
6361
|
+
| \`usr_\` | User profiles | usr_Profiles, usr_Preferences |
|
|
6362
|
+
| \`ai_\` | AI features | ai_Providers, ai_Models, ai_Prompts |
|
|
6363
|
+
| \`cfg_\` | Configuration | cfg_Settings |
|
|
6364
|
+
| \`wkf_\` | Workflows | wkf_EmailTemplates, wkf_Workflows |
|
|
6365
|
+
| \`support_\` | Support | support_Tickets, support_Comments |
|
|
6366
|
+
| \`entra_\` | Entra sync | entra_Groups, entra_SyncState |
|
|
6367
|
+
| \`ref_\` | References | ref_Companies, ref_Departments |
|
|
6368
|
+
| \`loc_\` | Localization | loc_Languages, loc_Translations |
|
|
6369
|
+
| \`lic_\` | Licensing | lic_Licenses |
|
|
6370
|
+
| \`tenant_\` | Multi-Tenancy | tenant_Tenants, tenant_TenantUsers, tenant_TenantUserRoles |
|
|
6371
|
+
|
|
6372
|
+
### Navigation Code Prefixes
|
|
6373
|
+
|
|
6374
|
+
All navigation data (Context, Application, Module, Section, Resource) uses code prefixes to distinguish system data from client extensions:
|
|
6375
|
+
|
|
6376
|
+
| Origin | Prefix | Usage | Example |
|
|
6377
|
+
|--------|--------|-------|---------|
|
|
6378
|
+
| SmartStack (system) | \`${codePrefixes.core}\` | Protected, delivered with SmartStack | \`${codePrefixes.core}administration\` |
|
|
6379
|
+
| Client (extension) | \`${codePrefixes.extension}\` | Custom, added by clients | \`${codePrefixes.extension}it\` |
|
|
6380
|
+
|
|
6381
|
+
**Navigation Hierarchy (5 levels):**
|
|
6382
|
+
|
|
6383
|
+
\`\`\`
|
|
6384
|
+
Context \u2192 Application \u2192 Module \u2192 Section \u2192 Resource
|
|
6385
|
+
\`\`\`
|
|
6386
|
+
|
|
6387
|
+
**Examples for each level:**
|
|
6388
|
+
|
|
6389
|
+
| Level | Core Example | Extension Example |
|
|
6390
|
+
|-------|--------------|-------------------|
|
|
6391
|
+
| Context | \`${codePrefixes.core}administration\` | \`${codePrefixes.extension}it\` |
|
|
6392
|
+
| Application | \`${codePrefixes.core}settings\` | \`${codePrefixes.extension}custom_app\` |
|
|
6393
|
+
| Module | \`${codePrefixes.core}users\` | \`${codePrefixes.extension}inventory\` |
|
|
6394
|
+
| Section | \`${codePrefixes.core}management\` | \`${codePrefixes.extension}reports\` |
|
|
6395
|
+
| Resource | \`${codePrefixes.core}user_list\` | \`${codePrefixes.extension}stock_view\` |
|
|
6396
|
+
|
|
6397
|
+
**Rules:**
|
|
6398
|
+
1. \`${codePrefixes.core}*\` codes are **protected** - clients cannot create or modify them
|
|
6399
|
+
2. \`${codePrefixes.extension}*\` codes are **free** - clients can create custom navigation
|
|
6400
|
+
3. SmartStack updates will never overwrite \`${codePrefixes.extension}*\` data
|
|
6401
|
+
4. Codes must be unique within their level (e.g., no two Contexts with same code)
|
|
6402
|
+
|
|
6403
|
+
### Entity Configuration
|
|
6404
|
+
|
|
6405
|
+
\`\`\`csharp
|
|
6406
|
+
public class MyEntityConfiguration : IEntityTypeConfiguration<MyEntity>
|
|
6407
|
+
{
|
|
6408
|
+
public void Configure(EntityTypeBuilder<MyEntity> builder)
|
|
6409
|
+
{
|
|
6410
|
+
// CORRECT: Use schema + domain prefix
|
|
6411
|
+
builder.ToTable("auth_Users", "${schemas.platform}");
|
|
6412
|
+
|
|
6413
|
+
// WRONG: No schema specified
|
|
6414
|
+
// builder.ToTable("auth_Users");
|
|
6415
|
+
|
|
6416
|
+
// WRONG: No domain prefix
|
|
6417
|
+
// builder.ToTable("Users", "${schemas.platform}");
|
|
6418
|
+
}
|
|
6419
|
+
}
|
|
6420
|
+
\`\`\`
|
|
6421
|
+
|
|
6422
|
+
### Extending Core Entities
|
|
6423
|
+
|
|
6424
|
+
When extending a core entity, use the \`${schemas.extensions}\` schema:
|
|
6425
|
+
|
|
6426
|
+
\`\`\`csharp
|
|
6427
|
+
// Client extension for auth_Users
|
|
6428
|
+
public class UserExtension
|
|
6429
|
+
{
|
|
6430
|
+
public Guid UserId { get; set; } // FK to auth_Users
|
|
6431
|
+
public User User { get; set; }
|
|
6432
|
+
|
|
6433
|
+
// Custom properties
|
|
6434
|
+
public string CustomField { get; set; }
|
|
6435
|
+
}
|
|
6436
|
+
|
|
6437
|
+
// Configuration - use extensions schema
|
|
6438
|
+
builder.ToTable("client_UserExtensions", "${schemas.extensions}");
|
|
6439
|
+
builder.HasOne(e => e.User)
|
|
6440
|
+
.WithOne()
|
|
6441
|
+
.HasForeignKey<UserExtension>(e => e.UserId);
|
|
6442
|
+
\`\`\`
|
|
6443
|
+
|
|
6444
|
+
---
|
|
6445
|
+
|
|
6446
|
+
## 2. Migration Conventions
|
|
6447
|
+
|
|
6448
|
+
### Naming Format
|
|
6449
|
+
|
|
6450
|
+
Migrations MUST follow this naming pattern:
|
|
6451
|
+
|
|
6452
|
+
\`\`\`
|
|
6453
|
+
${migrationFormat}
|
|
6454
|
+
\`\`\`
|
|
6455
|
+
|
|
6456
|
+
| Part | Description | Example |
|
|
6457
|
+
|------|-------------|---------|
|
|
6458
|
+
| \`{context}\` | DbContext name | \`core\`, \`extensions\` |
|
|
6459
|
+
| \`{version}\` | Semver version | \`v1.0.0\`, \`v1.2.0\` |
|
|
6460
|
+
| \`{sequence}\` | Order in version | \`001\`, \`002\` |
|
|
6461
|
+
| \`{Description}\` | Action (PascalCase) | \`CreateAuthUsers\` |
|
|
6462
|
+
|
|
6463
|
+
**Examples:**
|
|
6464
|
+
- \`core_v1.0.0_001_InitialSchema.cs\`
|
|
6465
|
+
- \`core_v1.0.0_002_CreateAuthUsers.cs\`
|
|
6466
|
+
- \`core_v1.2.0_001_AddUserProfiles.cs\`
|
|
6467
|
+
- \`extensions_v1.0.0_001_AddClientFeatures.cs\`
|
|
6468
|
+
|
|
6469
|
+
### Creating Migrations
|
|
6470
|
+
|
|
6471
|
+
\`\`\`bash
|
|
6472
|
+
# Create a new migration
|
|
6473
|
+
dotnet ef migrations add core_v1.0.0_001_InitialSchema
|
|
6474
|
+
|
|
6475
|
+
# With context specified
|
|
6476
|
+
dotnet ef migrations add core_v1.2.0_001_AddUserProfiles --context ApplicationDbContext
|
|
6477
|
+
\`\`\`
|
|
6478
|
+
|
|
6479
|
+
### Migration Rules
|
|
6480
|
+
|
|
6481
|
+
1. **One migration per feature** - Group related changes in a single migration
|
|
6482
|
+
2. **Version-based naming** - Use semver (v1.0.0, v1.2.0) to link migrations to releases
|
|
6483
|
+
3. **Sequence numbers** - Use NNN (001, 002, etc.) for migrations in the same version
|
|
6484
|
+
4. **Context prefix** - Use \`core_\` for platform tables, \`extensions_\` for client extensions
|
|
6485
|
+
5. **Descriptive names** - Use clear PascalCase descriptions (CreateAuthUsers, AddUserProfiles, etc.)
|
|
6486
|
+
6. **Schema must be specified** - All tables must specify their schema in ToTable()
|
|
6487
|
+
|
|
6488
|
+
---
|
|
6489
|
+
|
|
6490
|
+
## 3. Namespace Conventions
|
|
6491
|
+
|
|
6492
|
+
### Layer Structure
|
|
3618
6493
|
|
|
3619
6494
|
| Layer | Namespace | Purpose |
|
|
3620
6495
|
|-------|-----------|---------|
|
|
@@ -4432,7 +7307,7 @@ Run specific or all checks:
|
|
|
4432
7307
|
}
|
|
4433
7308
|
|
|
4434
7309
|
// src/resources/project-info.ts
|
|
4435
|
-
import
|
|
7310
|
+
import path14 from "path";
|
|
4436
7311
|
var projectInfoResourceTemplate = {
|
|
4437
7312
|
uri: "smartstack://project",
|
|
4438
7313
|
name: "SmartStack Project Info",
|
|
@@ -4469,16 +7344,16 @@ async function getProjectInfoResource(config) {
|
|
|
4469
7344
|
lines.push("```");
|
|
4470
7345
|
lines.push(`${projectInfo.name}/`);
|
|
4471
7346
|
if (structure.domain) {
|
|
4472
|
-
lines.push(`\u251C\u2500\u2500 ${
|
|
7347
|
+
lines.push(`\u251C\u2500\u2500 ${path14.basename(structure.domain)}/ # Domain layer (entities)`);
|
|
4473
7348
|
}
|
|
4474
7349
|
if (structure.application) {
|
|
4475
|
-
lines.push(`\u251C\u2500\u2500 ${
|
|
7350
|
+
lines.push(`\u251C\u2500\u2500 ${path14.basename(structure.application)}/ # Application layer (services)`);
|
|
4476
7351
|
}
|
|
4477
7352
|
if (structure.infrastructure) {
|
|
4478
|
-
lines.push(`\u251C\u2500\u2500 ${
|
|
7353
|
+
lines.push(`\u251C\u2500\u2500 ${path14.basename(structure.infrastructure)}/ # Infrastructure (EF Core)`);
|
|
4479
7354
|
}
|
|
4480
7355
|
if (structure.api) {
|
|
4481
|
-
lines.push(`\u251C\u2500\u2500 ${
|
|
7356
|
+
lines.push(`\u251C\u2500\u2500 ${path14.basename(structure.api)}/ # API layer (controllers)`);
|
|
4482
7357
|
}
|
|
4483
7358
|
if (structure.web) {
|
|
4484
7359
|
lines.push(`\u2514\u2500\u2500 web/smartstack-web/ # React frontend`);
|
|
@@ -4491,8 +7366,8 @@ async function getProjectInfoResource(config) {
|
|
|
4491
7366
|
lines.push("| Project | Path |");
|
|
4492
7367
|
lines.push("|---------|------|");
|
|
4493
7368
|
for (const csproj of projectInfo.csprojFiles) {
|
|
4494
|
-
const name =
|
|
4495
|
-
const relativePath =
|
|
7369
|
+
const name = path14.basename(csproj, ".csproj");
|
|
7370
|
+
const relativePath = path14.relative(projectPath, csproj);
|
|
4496
7371
|
lines.push(`| ${name} | \`${relativePath}\` |`);
|
|
4497
7372
|
}
|
|
4498
7373
|
lines.push("");
|
|
@@ -4502,10 +7377,10 @@ async function getProjectInfoResource(config) {
|
|
|
4502
7377
|
cwd: structure.migrations,
|
|
4503
7378
|
ignore: ["*.Designer.cs"]
|
|
4504
7379
|
});
|
|
4505
|
-
const migrations = migrationFiles.map((f) =>
|
|
7380
|
+
const migrations = migrationFiles.map((f) => path14.basename(f)).filter((f) => !f.includes("ModelSnapshot") && !f.includes(".Designer.")).sort();
|
|
4506
7381
|
lines.push("## EF Core Migrations");
|
|
4507
7382
|
lines.push("");
|
|
4508
|
-
lines.push(`**Location**: \`${
|
|
7383
|
+
lines.push(`**Location**: \`${path14.relative(projectPath, structure.migrations)}\``);
|
|
4509
7384
|
lines.push(`**Total Migrations**: ${migrations.length}`);
|
|
4510
7385
|
lines.push("");
|
|
4511
7386
|
if (migrations.length > 0) {
|
|
@@ -4540,11 +7415,11 @@ async function getProjectInfoResource(config) {
|
|
|
4540
7415
|
lines.push("dotnet build");
|
|
4541
7416
|
lines.push("");
|
|
4542
7417
|
lines.push("# Run API");
|
|
4543
|
-
lines.push(`cd ${structure.api ?
|
|
7418
|
+
lines.push(`cd ${structure.api ? path14.relative(projectPath, structure.api) : "SmartStack.Api"}`);
|
|
4544
7419
|
lines.push("dotnet run");
|
|
4545
7420
|
lines.push("");
|
|
4546
7421
|
lines.push("# Run frontend");
|
|
4547
|
-
lines.push(`cd ${structure.web ?
|
|
7422
|
+
lines.push(`cd ${structure.web ? path14.relative(projectPath, structure.web) : "web/smartstack-web"}`);
|
|
4548
7423
|
lines.push("npm run dev");
|
|
4549
7424
|
lines.push("");
|
|
4550
7425
|
lines.push("# Create migration");
|
|
@@ -4567,7 +7442,7 @@ async function getProjectInfoResource(config) {
|
|
|
4567
7442
|
}
|
|
4568
7443
|
|
|
4569
7444
|
// src/resources/api-endpoints.ts
|
|
4570
|
-
import
|
|
7445
|
+
import path15 from "path";
|
|
4571
7446
|
var apiEndpointsResourceTemplate = {
|
|
4572
7447
|
uri: "smartstack://api/",
|
|
4573
7448
|
name: "SmartStack API Endpoints",
|
|
@@ -4592,7 +7467,7 @@ async function getApiEndpointsResource(config, endpointFilter) {
|
|
|
4592
7467
|
}
|
|
4593
7468
|
async function parseController(filePath, _rootPath) {
|
|
4594
7469
|
const content = await readText(filePath);
|
|
4595
|
-
const fileName =
|
|
7470
|
+
const fileName = path15.basename(filePath, ".cs");
|
|
4596
7471
|
const controllerName = fileName.replace("Controller", "");
|
|
4597
7472
|
const endpoints = [];
|
|
4598
7473
|
const routeMatch = content.match(/\[Route\s*\(\s*"([^"]+)"\s*\)\]/);
|
|
@@ -4739,7 +7614,7 @@ function getMethodEmoji(method) {
|
|
|
4739
7614
|
}
|
|
4740
7615
|
|
|
4741
7616
|
// src/resources/db-schema.ts
|
|
4742
|
-
import
|
|
7617
|
+
import path16 from "path";
|
|
4743
7618
|
var dbSchemaResourceTemplate = {
|
|
4744
7619
|
uri: "smartstack://schema/",
|
|
4745
7620
|
name: "SmartStack Database Schema",
|
|
@@ -4829,7 +7704,7 @@ async function parseEntity(filePath, rootPath, _config) {
|
|
|
4829
7704
|
tableName,
|
|
4830
7705
|
properties,
|
|
4831
7706
|
relationships,
|
|
4832
|
-
file:
|
|
7707
|
+
file: path16.relative(rootPath, filePath)
|
|
4833
7708
|
};
|
|
4834
7709
|
}
|
|
4835
7710
|
async function enrichFromConfigurations(entities, infrastructurePath, _config) {
|
|
@@ -4975,7 +7850,7 @@ function formatSchema(entities, filter, _config) {
|
|
|
4975
7850
|
}
|
|
4976
7851
|
|
|
4977
7852
|
// src/resources/entities.ts
|
|
4978
|
-
import
|
|
7853
|
+
import path17 from "path";
|
|
4979
7854
|
var entitiesResourceTemplate = {
|
|
4980
7855
|
uri: "smartstack://entities/",
|
|
4981
7856
|
name: "SmartStack Entities",
|
|
@@ -5035,7 +7910,7 @@ async function parseEntitySummary(filePath, rootPath, config) {
|
|
|
5035
7910
|
hasSoftDelete,
|
|
5036
7911
|
hasRowVersion,
|
|
5037
7912
|
file: filePath,
|
|
5038
|
-
relativePath:
|
|
7913
|
+
relativePath: path17.relative(rootPath, filePath)
|
|
5039
7914
|
};
|
|
5040
7915
|
}
|
|
5041
7916
|
function inferTableInfo(entityName, config) {
|
|
@@ -5222,7 +8097,12 @@ async function createServer() {
|
|
|
5222
8097
|
checkMigrationsTool,
|
|
5223
8098
|
scaffoldExtensionTool,
|
|
5224
8099
|
apiDocsTool,
|
|
5225
|
-
suggestMigrationTool
|
|
8100
|
+
suggestMigrationTool,
|
|
8101
|
+
// Test Tools
|
|
8102
|
+
scaffoldTestsTool,
|
|
8103
|
+
analyzeTestCoverageTool,
|
|
8104
|
+
validateTestConventionsTool,
|
|
8105
|
+
suggestTestScenariosTool
|
|
5226
8106
|
]
|
|
5227
8107
|
};
|
|
5228
8108
|
});
|
|
@@ -5248,6 +8128,19 @@ async function createServer() {
|
|
|
5248
8128
|
case "suggest_migration":
|
|
5249
8129
|
result = await handleSuggestMigration(args, config);
|
|
5250
8130
|
break;
|
|
8131
|
+
// Test Tools
|
|
8132
|
+
case "scaffold_tests":
|
|
8133
|
+
result = await handleScaffoldTests(args, config);
|
|
8134
|
+
break;
|
|
8135
|
+
case "analyze_test_coverage":
|
|
8136
|
+
result = await handleAnalyzeTestCoverage(args, config);
|
|
8137
|
+
break;
|
|
8138
|
+
case "validate_test_conventions":
|
|
8139
|
+
result = await handleValidateTestConventions(args, config);
|
|
8140
|
+
break;
|
|
8141
|
+
case "suggest_test_scenarios":
|
|
8142
|
+
result = await handleSuggestTestScenarios(args, config);
|
|
8143
|
+
break;
|
|
5251
8144
|
default:
|
|
5252
8145
|
throw new Error(`Unknown tool: ${name}`);
|
|
5253
8146
|
}
|