@atlashub/smartstack-mcp 1.4.1 → 1.6.0
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 +4331 -148
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
- package/templates/frontend/api-client.ts.hbs +116 -0
- package/templates/frontend/nav-routes.ts.hbs +133 -0
- package/templates/frontend/routes.tsx.hbs +134 -0
- 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, path21, cause) {
|
|
86
86
|
super(message);
|
|
87
87
|
this.operation = operation;
|
|
88
|
-
this.path =
|
|
88
|
+
this.path = path21;
|
|
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,70 @@ 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
|
+
});
|
|
494
|
+
var ScaffoldApiClientInputSchema = z.object({
|
|
495
|
+
navRoute: z.string().min(1).describe('NavRoute path (e.g., "platform.administration.users")'),
|
|
496
|
+
name: z.string().min(1).describe('Entity name in PascalCase (e.g., "User", "Order")'),
|
|
497
|
+
methods: z.array(z.enum(["getAll", "getById", "create", "update", "delete", "search", "export"])).default(["getAll", "getById", "create", "update", "delete"]).describe("API methods to generate"),
|
|
498
|
+
options: z.object({
|
|
499
|
+
outputPath: z.string().optional().describe("Custom output path for generated files"),
|
|
500
|
+
includeTypes: z.boolean().default(true).describe("Generate TypeScript types"),
|
|
501
|
+
includeHook: z.boolean().default(true).describe("Generate React Query hook"),
|
|
502
|
+
dryRun: z.boolean().default(false).describe("Preview without writing files")
|
|
503
|
+
}).optional()
|
|
504
|
+
});
|
|
505
|
+
var ScaffoldRoutesInputSchema = z.object({
|
|
506
|
+
source: z.enum(["controllers", "navigation", "manual"]).default("controllers").describe("Source for route discovery: controllers (scan NavRoute attributes), navigation (from DB), manual (from config)"),
|
|
507
|
+
scope: z.enum(["all", "platform", "business", "extensions"]).default("all").describe("Scope of routes to generate"),
|
|
508
|
+
options: z.object({
|
|
509
|
+
outputPath: z.string().optional().describe("Custom output path"),
|
|
510
|
+
includeLayouts: z.boolean().default(true).describe("Generate layout components"),
|
|
511
|
+
includeGuards: z.boolean().default(true).describe("Include route guards for permissions"),
|
|
512
|
+
generateRegistry: z.boolean().default(true).describe("Generate navRoutes.generated.ts"),
|
|
513
|
+
dryRun: z.boolean().default(false).describe("Preview without writing files")
|
|
514
|
+
}).optional()
|
|
515
|
+
});
|
|
516
|
+
var ValidateFrontendRoutesInputSchema = z.object({
|
|
517
|
+
scope: z.enum(["api-clients", "routes", "registry", "all"]).default("all").describe("Scope of validation"),
|
|
518
|
+
options: z.object({
|
|
519
|
+
fix: z.boolean().default(false).describe("Auto-fix minor issues"),
|
|
520
|
+
strict: z.boolean().default(false).describe("Fail on warnings")
|
|
521
|
+
}).optional()
|
|
522
|
+
});
|
|
459
523
|
|
|
460
524
|
// src/lib/detector.ts
|
|
461
525
|
import path4 from "path";
|
|
@@ -2398,7 +2462,7 @@ export function use{{name}}(options: Use{{name}}Options = {}) {
|
|
|
2398
2462
|
}
|
|
2399
2463
|
async function scaffoldTest(name, options, structure, config, result, dryRun = false) {
|
|
2400
2464
|
const isSystemEntity = options?.isSystemEntity || false;
|
|
2401
|
-
const
|
|
2465
|
+
const serviceTestTemplate2 = `using System;
|
|
2402
2466
|
using System.Threading;
|
|
2403
2467
|
using System.Threading.Tasks;
|
|
2404
2468
|
using Microsoft.Extensions.Logging;
|
|
@@ -2505,7 +2569,7 @@ public class {{name}}ServiceTests
|
|
|
2505
2569
|
name,
|
|
2506
2570
|
isSystemEntity
|
|
2507
2571
|
};
|
|
2508
|
-
const testContent = Handlebars.compile(
|
|
2572
|
+
const testContent = Handlebars.compile(serviceTestTemplate2)(context);
|
|
2509
2573
|
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
2574
|
const testFilePath = path7.join(testsPath, `${name}ServiceTests.cs`);
|
|
2511
2575
|
if (!dryRun) {
|
|
@@ -3446,164 +3510,4251 @@ function compareVersions2(a, b) {
|
|
|
3446
3510
|
return 0;
|
|
3447
3511
|
}
|
|
3448
3512
|
|
|
3449
|
-
// src/
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3513
|
+
// src/tools/scaffold-tests.ts
|
|
3514
|
+
import Handlebars2 from "handlebars";
|
|
3515
|
+
import path10 from "path";
|
|
3516
|
+
var scaffoldTestsTool = {
|
|
3517
|
+
name: "scaffold_tests",
|
|
3518
|
+
description: "Generate unit, integration, and security tests for SmartStack entities, services, controllers, validators, and repositories. Ensures non-regression and maximum security coverage.",
|
|
3519
|
+
inputSchema: {
|
|
3520
|
+
type: "object",
|
|
3521
|
+
properties: {
|
|
3522
|
+
target: {
|
|
3523
|
+
type: "string",
|
|
3524
|
+
enum: ["entity", "service", "controller", "validator", "repository", "all"],
|
|
3525
|
+
description: "Type of component to test"
|
|
3526
|
+
},
|
|
3527
|
+
name: {
|
|
3528
|
+
type: "string",
|
|
3529
|
+
description: 'Component name (PascalCase, e.g., "User", "Order")'
|
|
3530
|
+
},
|
|
3531
|
+
testTypes: {
|
|
3532
|
+
type: "array",
|
|
3533
|
+
items: {
|
|
3534
|
+
type: "string",
|
|
3535
|
+
enum: ["unit", "integration", "security", "e2e"]
|
|
3536
|
+
},
|
|
3537
|
+
default: ["unit"],
|
|
3538
|
+
description: "Types of tests to generate"
|
|
3539
|
+
},
|
|
3540
|
+
options: {
|
|
3541
|
+
type: "object",
|
|
3542
|
+
properties: {
|
|
3543
|
+
includeEdgeCases: {
|
|
3544
|
+
type: "boolean",
|
|
3545
|
+
default: true,
|
|
3546
|
+
description: "Include edge case tests"
|
|
3547
|
+
},
|
|
3548
|
+
includeTenantIsolation: {
|
|
3549
|
+
type: "boolean",
|
|
3550
|
+
default: true,
|
|
3551
|
+
description: "Include tenant isolation tests"
|
|
3552
|
+
},
|
|
3553
|
+
includeSoftDelete: {
|
|
3554
|
+
type: "boolean",
|
|
3555
|
+
default: true,
|
|
3556
|
+
description: "Include soft delete tests"
|
|
3557
|
+
},
|
|
3558
|
+
includeAudit: {
|
|
3559
|
+
type: "boolean",
|
|
3560
|
+
default: true,
|
|
3561
|
+
description: "Include audit trail tests"
|
|
3562
|
+
},
|
|
3563
|
+
includeValidation: {
|
|
3564
|
+
type: "boolean",
|
|
3565
|
+
default: true,
|
|
3566
|
+
description: "Include validation tests"
|
|
3567
|
+
},
|
|
3568
|
+
includeAuthorization: {
|
|
3569
|
+
type: "boolean",
|
|
3570
|
+
default: false,
|
|
3571
|
+
description: "Include authorization tests"
|
|
3572
|
+
},
|
|
3573
|
+
includePerformance: {
|
|
3574
|
+
type: "boolean",
|
|
3575
|
+
default: false,
|
|
3576
|
+
description: "Include performance tests"
|
|
3577
|
+
},
|
|
3578
|
+
isSystemEntity: {
|
|
3579
|
+
type: "boolean",
|
|
3580
|
+
default: false,
|
|
3581
|
+
description: "If true, entity has no TenantId"
|
|
3582
|
+
},
|
|
3583
|
+
dryRun: {
|
|
3584
|
+
type: "boolean",
|
|
3585
|
+
default: false,
|
|
3586
|
+
description: "Preview without writing files"
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
},
|
|
3591
|
+
required: ["target", "name"]
|
|
3592
|
+
}
|
|
3455
3593
|
};
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
return
|
|
3594
|
+
Handlebars2.registerHelper("pascalCase", (str) => {
|
|
3595
|
+
if (!str) return "";
|
|
3596
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
3597
|
+
});
|
|
3598
|
+
Handlebars2.registerHelper("camelCase", (str) => {
|
|
3599
|
+
if (!str) return "";
|
|
3600
|
+
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
3601
|
+
});
|
|
3602
|
+
Handlebars2.registerHelper("unless", function(conditional, options) {
|
|
3603
|
+
if (!conditional) {
|
|
3604
|
+
return options.fn(this);
|
|
3605
|
+
}
|
|
3606
|
+
return options.inverse(this);
|
|
3607
|
+
});
|
|
3608
|
+
var entityTestTemplate = `using System;
|
|
3609
|
+
using FluentAssertions;
|
|
3610
|
+
using Xunit;
|
|
3611
|
+
using {{domainNamespace}};
|
|
3459
3612
|
|
|
3460
|
-
|
|
3613
|
+
namespace {{testNamespace}}.Unit.Domain;
|
|
3461
3614
|
|
|
3462
|
-
|
|
3463
|
-
|
|
3615
|
+
/// <summary>
|
|
3616
|
+
/// Unit tests for {{name}} entity
|
|
3617
|
+
/// Tests: Factory methods, Soft delete, Audit trail, Validation
|
|
3618
|
+
/// </summary>
|
|
3619
|
+
public class {{name}}Tests
|
|
3620
|
+
{
|
|
3621
|
+
private const string ValidCode = "test_code";
|
|
3622
|
+
{{#unless isSystemEntity}}
|
|
3623
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
3624
|
+
{{/unless}}
|
|
3464
3625
|
|
|
3465
|
-
|
|
3626
|
+
#region Factory Method Tests
|
|
3466
3627
|
|
|
3467
|
-
|
|
3628
|
+
[Fact]
|
|
3629
|
+
public void Create_WhenValidData_ShouldCreateEntity()
|
|
3630
|
+
{
|
|
3631
|
+
// Arrange
|
|
3632
|
+
var code = ValidCode;
|
|
3633
|
+
{{#unless isSystemEntity}}
|
|
3634
|
+
var tenantId = _tenantId;
|
|
3635
|
+
{{/unless}}
|
|
3468
3636
|
|
|
3469
|
-
|
|
3637
|
+
// Act
|
|
3638
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}tenantId, {{/unless}}code);
|
|
3470
3639
|
|
|
3471
|
-
|
|
3640
|
+
// Assert
|
|
3641
|
+
entity.Should().NotBeNull();
|
|
3642
|
+
entity.Id.Should().NotBeEmpty();
|
|
3643
|
+
entity.Code.Should().Be(code.ToLowerInvariant());
|
|
3644
|
+
{{#unless isSystemEntity}}
|
|
3645
|
+
entity.TenantId.Should().Be(tenantId);
|
|
3646
|
+
{{/unless}}
|
|
3647
|
+
entity.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
3648
|
+
entity.IsDeleted.Should().BeFalse();
|
|
3649
|
+
}
|
|
3472
3650
|
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3651
|
+
{{#unless isSystemEntity}}
|
|
3652
|
+
[Fact]
|
|
3653
|
+
public void Create_WhenEmptyTenantId_ShouldThrow()
|
|
3654
|
+
{
|
|
3655
|
+
// Arrange
|
|
3656
|
+
var tenantId = Guid.Empty;
|
|
3657
|
+
var code = ValidCode;
|
|
3477
3658
|
|
|
3478
|
-
|
|
3659
|
+
// Act
|
|
3660
|
+
var act = () => {{name}}.Create(tenantId, code);
|
|
3479
3661
|
|
|
3480
|
-
|
|
3662
|
+
// Assert
|
|
3663
|
+
act.Should().Throw<ArgumentException>()
|
|
3664
|
+
.WithParameterName("tenantId");
|
|
3665
|
+
}
|
|
3666
|
+
{{/unless}}
|
|
3481
3667
|
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
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 |
|
|
3668
|
+
[Theory]
|
|
3669
|
+
[InlineData("")]
|
|
3670
|
+
[InlineData(" ")]
|
|
3671
|
+
public void Create_WhenEmptyCode_ShouldHandleGracefully(string code)
|
|
3672
|
+
{
|
|
3673
|
+
// Arrange
|
|
3674
|
+
{{#unless isSystemEntity}}
|
|
3675
|
+
var tenantId = _tenantId;
|
|
3676
|
+
{{/unless}}
|
|
3496
3677
|
|
|
3497
|
-
|
|
3678
|
+
// Act
|
|
3679
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}tenantId, {{/unless}}code);
|
|
3498
3680
|
|
|
3499
|
-
|
|
3681
|
+
// Assert
|
|
3682
|
+
entity.Should().NotBeNull();
|
|
3683
|
+
entity.Code.Should().BeEmpty();
|
|
3684
|
+
}
|
|
3500
3685
|
|
|
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\` |
|
|
3686
|
+
#endregion
|
|
3505
3687
|
|
|
3506
|
-
|
|
3688
|
+
{{#if includeSoftDelete}}
|
|
3689
|
+
#region Soft Delete Tests
|
|
3507
3690
|
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3691
|
+
[Fact]
|
|
3692
|
+
public void SoftDelete_WhenCalled_ShouldSetIsDeletedTrue()
|
|
3693
|
+
{
|
|
3694
|
+
// Arrange
|
|
3695
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3696
|
+
var deletedBy = "test_user";
|
|
3511
3697
|
|
|
3512
|
-
|
|
3698
|
+
// Act
|
|
3699
|
+
entity.SoftDelete(deletedBy);
|
|
3513
3700
|
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
| Resource | \`${codePrefixes.core}user_list\` | \`${codePrefixes.extension}stock_view\` |
|
|
3701
|
+
// Assert
|
|
3702
|
+
entity.IsDeleted.Should().BeTrue();
|
|
3703
|
+
entity.DeletedAt.Should().NotBeNull();
|
|
3704
|
+
entity.DeletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
3705
|
+
entity.DeletedBy.Should().Be(deletedBy);
|
|
3706
|
+
}
|
|
3521
3707
|
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3708
|
+
[Fact]
|
|
3709
|
+
public void SoftDelete_WhenAlreadyDeleted_ShouldNotChangeDeletedAt()
|
|
3710
|
+
{
|
|
3711
|
+
// Arrange
|
|
3712
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3713
|
+
entity.SoftDelete("first_deleter");
|
|
3714
|
+
var originalDeletedAt = entity.DeletedAt;
|
|
3527
3715
|
|
|
3528
|
-
|
|
3716
|
+
// Act
|
|
3717
|
+
entity.SoftDelete("second_deleter");
|
|
3529
3718
|
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3719
|
+
// Assert
|
|
3720
|
+
entity.DeletedAt.Should().Be(originalDeletedAt);
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
[Fact]
|
|
3724
|
+
public void Restore_WhenSoftDeleted_ShouldClearDeletedFields()
|
|
3534
3725
|
{
|
|
3535
|
-
//
|
|
3536
|
-
|
|
3726
|
+
// Arrange
|
|
3727
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3728
|
+
entity.SoftDelete("deleter");
|
|
3729
|
+
var restoredBy = "restorer";
|
|
3537
3730
|
|
|
3538
|
-
//
|
|
3539
|
-
|
|
3731
|
+
// Act
|
|
3732
|
+
entity.Restore(restoredBy);
|
|
3540
3733
|
|
|
3541
|
-
//
|
|
3542
|
-
|
|
3734
|
+
// Assert
|
|
3735
|
+
entity.IsDeleted.Should().BeFalse();
|
|
3736
|
+
entity.DeletedAt.Should().BeNull();
|
|
3737
|
+
entity.DeletedBy.Should().BeNull();
|
|
3738
|
+
entity.UpdatedBy.Should().Be(restoredBy);
|
|
3543
3739
|
}
|
|
3544
|
-
}
|
|
3545
|
-
\`\`\`
|
|
3546
3740
|
|
|
3547
|
-
|
|
3741
|
+
#endregion
|
|
3742
|
+
{{/if}}
|
|
3548
3743
|
|
|
3549
|
-
|
|
3744
|
+
{{#if includeAudit}}
|
|
3745
|
+
#region Audit Trail Tests
|
|
3550
3746
|
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
public User User { get; set; }
|
|
3747
|
+
[Fact]
|
|
3748
|
+
public void Create_ShouldSetCreatedAtToUtcNow()
|
|
3749
|
+
{
|
|
3750
|
+
// Arrange & Act
|
|
3751
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3557
3752
|
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3753
|
+
// Assert
|
|
3754
|
+
entity.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
3755
|
+
entity.CreatedAt.Kind.Should().Be(DateTimeKind.Utc);
|
|
3756
|
+
}
|
|
3561
3757
|
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
\`\`\`
|
|
3758
|
+
[Fact]
|
|
3759
|
+
public void Create_WhenCreatedByProvided_ShouldSetCreatedBy()
|
|
3760
|
+
{
|
|
3761
|
+
// Arrange
|
|
3762
|
+
var createdBy = "test_creator";
|
|
3568
3763
|
|
|
3569
|
-
|
|
3764
|
+
// Act
|
|
3765
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode, createdBy);
|
|
3570
3766
|
|
|
3571
|
-
|
|
3767
|
+
// Assert
|
|
3768
|
+
entity.CreatedBy.Should().Be(createdBy);
|
|
3769
|
+
}
|
|
3572
3770
|
|
|
3573
|
-
|
|
3771
|
+
[Fact]
|
|
3772
|
+
public void Update_WhenCalled_ShouldUpdateAuditFields()
|
|
3773
|
+
{
|
|
3774
|
+
// Arrange
|
|
3775
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
|
|
3776
|
+
var originalCreatedAt = entity.CreatedAt;
|
|
3777
|
+
var updatedBy = "updater";
|
|
3574
3778
|
|
|
3575
|
-
|
|
3779
|
+
// Act
|
|
3780
|
+
entity.Update(updatedBy);
|
|
3576
3781
|
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3782
|
+
// Assert
|
|
3783
|
+
entity.UpdatedAt.Should().NotBeNull();
|
|
3784
|
+
entity.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
3785
|
+
entity.UpdatedBy.Should().Be(updatedBy);
|
|
3786
|
+
entity.CreatedAt.Should().Be(originalCreatedAt);
|
|
3787
|
+
}
|
|
3580
3788
|
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
| \`{context}\` | DbContext name | \`core\`, \`extensions\` |
|
|
3584
|
-
| \`{version}\` | Semver version | \`v1.0.0\`, \`v1.2.0\` |
|
|
3585
|
-
| \`{sequence}\` | Order in version | \`001\`, \`002\` |
|
|
3586
|
-
| \`{Description}\` | Action (PascalCase) | \`CreateAuthUsers\` |
|
|
3789
|
+
#endregion
|
|
3790
|
+
{{/if}}
|
|
3587
3791
|
|
|
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\`
|
|
3792
|
+
{{#if includeEdgeCases}}
|
|
3793
|
+
#region Edge Cases
|
|
3593
3794
|
|
|
3594
|
-
|
|
3795
|
+
[Fact]
|
|
3796
|
+
public void Create_WithSpecialCharactersInCode_ShouldNormalize()
|
|
3797
|
+
{
|
|
3798
|
+
// Arrange
|
|
3799
|
+
var code = "Test-Code_123";
|
|
3595
3800
|
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
dotnet ef migrations add core_v1.0.0_001_InitialSchema
|
|
3801
|
+
// Act
|
|
3802
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}code);
|
|
3599
3803
|
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3804
|
+
// Assert
|
|
3805
|
+
entity.Code.Should().Be(code.ToLowerInvariant());
|
|
3806
|
+
}
|
|
3603
3807
|
|
|
3604
|
-
|
|
3808
|
+
[Fact]
|
|
3809
|
+
public void Create_WithMaxLengthCode_ShouldSucceed()
|
|
3810
|
+
{
|
|
3811
|
+
// Arrange
|
|
3812
|
+
var code = new string('a', 100);
|
|
3605
3813
|
|
|
3606
|
-
|
|
3814
|
+
// Act
|
|
3815
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}code);
|
|
3816
|
+
|
|
3817
|
+
// Assert
|
|
3818
|
+
entity.Code.Should().HaveLength(100);
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
#endregion
|
|
3822
|
+
{{/if}}
|
|
3823
|
+
}
|
|
3824
|
+
`;
|
|
3825
|
+
var serviceTestTemplate = `using System;
|
|
3826
|
+
using System.Collections.Generic;
|
|
3827
|
+
using System.Threading;
|
|
3828
|
+
using System.Threading.Tasks;
|
|
3829
|
+
using FluentAssertions;
|
|
3830
|
+
using Microsoft.Extensions.Logging;
|
|
3831
|
+
using Moq;
|
|
3832
|
+
using Xunit;
|
|
3833
|
+
using {{applicationNamespace}}.Services;
|
|
3834
|
+
using {{applicationNamespace}}.Repositories;
|
|
3835
|
+
using {{domainNamespace}};
|
|
3836
|
+
|
|
3837
|
+
namespace {{testNamespace}}.Unit.Services;
|
|
3838
|
+
|
|
3839
|
+
/// <summary>
|
|
3840
|
+
/// Unit tests for {{name}}Service
|
|
3841
|
+
/// Tests: CRUD operations, Business logic, Error handling, Tenant isolation
|
|
3842
|
+
/// </summary>
|
|
3843
|
+
public class {{name}}ServiceTests
|
|
3844
|
+
{
|
|
3845
|
+
private readonly Mock<I{{name}}Repository> _repositoryMock;
|
|
3846
|
+
private readonly Mock<ILogger<{{name}}Service>> _loggerMock;
|
|
3847
|
+
private readonly {{name}}Service _sut;
|
|
3848
|
+
{{#unless isSystemEntity}}
|
|
3849
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
3850
|
+
{{/unless}}
|
|
3851
|
+
|
|
3852
|
+
public {{name}}ServiceTests()
|
|
3853
|
+
{
|
|
3854
|
+
_repositoryMock = new Mock<I{{name}}Repository>();
|
|
3855
|
+
_loggerMock = new Mock<ILogger<{{name}}Service>>();
|
|
3856
|
+
_sut = new {{name}}Service(
|
|
3857
|
+
_repositoryMock.Object,
|
|
3858
|
+
_loggerMock.Object
|
|
3859
|
+
);
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
#region GetByIdAsync Tests
|
|
3863
|
+
|
|
3864
|
+
[Fact]
|
|
3865
|
+
public async Task GetByIdAsync_WhenEntityExists_ShouldReturnEntity()
|
|
3866
|
+
{
|
|
3867
|
+
// Arrange
|
|
3868
|
+
var id = Guid.NewGuid();
|
|
3869
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
3870
|
+
_repositoryMock
|
|
3871
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3872
|
+
.ReturnsAsync(entity);
|
|
3873
|
+
|
|
3874
|
+
// Act
|
|
3875
|
+
var result = await _sut.GetByIdAsync(id);
|
|
3876
|
+
|
|
3877
|
+
// Assert
|
|
3878
|
+
result.Should().NotBeNull();
|
|
3879
|
+
_repositoryMock.Verify(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()), Times.Once);
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
[Fact]
|
|
3883
|
+
public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull()
|
|
3884
|
+
{
|
|
3885
|
+
// Arrange
|
|
3886
|
+
var id = Guid.NewGuid();
|
|
3887
|
+
_repositoryMock
|
|
3888
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3889
|
+
.ReturnsAsync(({{name}}?)null);
|
|
3890
|
+
|
|
3891
|
+
// Act
|
|
3892
|
+
var result = await _sut.GetByIdAsync(id);
|
|
3893
|
+
|
|
3894
|
+
// Assert
|
|
3895
|
+
result.Should().BeNull();
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
[Fact]
|
|
3899
|
+
public async Task GetByIdAsync_WhenCancelled_ShouldThrowOperationCancelledException()
|
|
3900
|
+
{
|
|
3901
|
+
// Arrange
|
|
3902
|
+
var id = Guid.NewGuid();
|
|
3903
|
+
var cts = new CancellationTokenSource();
|
|
3904
|
+
cts.Cancel();
|
|
3905
|
+
|
|
3906
|
+
_repositoryMock
|
|
3907
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3908
|
+
.ThrowsAsync(new OperationCanceledException());
|
|
3909
|
+
|
|
3910
|
+
// Act
|
|
3911
|
+
var act = async () => await _sut.GetByIdAsync(id, cts.Token);
|
|
3912
|
+
|
|
3913
|
+
// Assert
|
|
3914
|
+
await act.Should().ThrowAsync<OperationCanceledException>();
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
#endregion
|
|
3918
|
+
|
|
3919
|
+
{{#unless isSystemEntity}}
|
|
3920
|
+
#region Tenant Isolation Tests
|
|
3921
|
+
|
|
3922
|
+
[Fact]
|
|
3923
|
+
public async Task GetAllAsync_ShouldFilterByTenant()
|
|
3924
|
+
{
|
|
3925
|
+
// Arrange
|
|
3926
|
+
var entities = new List<{{name}}>
|
|
3927
|
+
{
|
|
3928
|
+
{{name}}.Create(_tenantId, "entity1"),
|
|
3929
|
+
{{name}}.Create(_tenantId, "entity2"),
|
|
3930
|
+
};
|
|
3931
|
+
_repositoryMock
|
|
3932
|
+
.Setup(r => r.GetAllByTenantAsync(_tenantId, It.IsAny<CancellationToken>()))
|
|
3933
|
+
.ReturnsAsync(entities);
|
|
3934
|
+
|
|
3935
|
+
// Act
|
|
3936
|
+
var result = await _sut.GetAllAsync(_tenantId);
|
|
3937
|
+
|
|
3938
|
+
// Assert
|
|
3939
|
+
result.Should().HaveCount(2);
|
|
3940
|
+
result.Should().OnlyContain(e => e.TenantId == _tenantId);
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
[Fact]
|
|
3944
|
+
public async Task CreateAsync_ShouldSetTenantId()
|
|
3945
|
+
{
|
|
3946
|
+
// Arrange
|
|
3947
|
+
{{name}}? capturedEntity = null;
|
|
3948
|
+
_repositoryMock
|
|
3949
|
+
.Setup(r => r.AddAsync(It.IsAny<{{name}}>(), It.IsAny<CancellationToken>()))
|
|
3950
|
+
.Callback<{{name}}, CancellationToken>((e, _) => capturedEntity = e)
|
|
3951
|
+
.ReturnsAsync(({{name}} e, CancellationToken _) => e);
|
|
3952
|
+
|
|
3953
|
+
// Act
|
|
3954
|
+
await _sut.CreateAsync(_tenantId, "test_code");
|
|
3955
|
+
|
|
3956
|
+
// Assert
|
|
3957
|
+
capturedEntity.Should().NotBeNull();
|
|
3958
|
+
capturedEntity!.TenantId.Should().Be(_tenantId);
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
[Fact]
|
|
3962
|
+
public async Task GetByIdAsync_WhenEntityBelongsToDifferentTenant_ShouldReturnNull()
|
|
3963
|
+
{
|
|
3964
|
+
// Arrange
|
|
3965
|
+
var id = Guid.NewGuid();
|
|
3966
|
+
var otherTenantId = Guid.NewGuid();
|
|
3967
|
+
var entity = {{name}}.Create(otherTenantId, "test");
|
|
3968
|
+
|
|
3969
|
+
_repositoryMock
|
|
3970
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3971
|
+
.ReturnsAsync(({{name}}?)null); // Repository should filter by tenant
|
|
3972
|
+
|
|
3973
|
+
// Act
|
|
3974
|
+
var result = await _sut.GetByIdAsync(id, _tenantId);
|
|
3975
|
+
|
|
3976
|
+
// Assert
|
|
3977
|
+
result.Should().BeNull();
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
#endregion
|
|
3981
|
+
{{/unless}}
|
|
3982
|
+
|
|
3983
|
+
#region Error Handling Tests
|
|
3984
|
+
|
|
3985
|
+
[Fact]
|
|
3986
|
+
public async Task UpdateAsync_WhenEntityNotFound_ShouldThrow()
|
|
3987
|
+
{
|
|
3988
|
+
// Arrange
|
|
3989
|
+
var id = Guid.NewGuid();
|
|
3990
|
+
_repositoryMock
|
|
3991
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
3992
|
+
.ReturnsAsync(({{name}}?)null);
|
|
3993
|
+
|
|
3994
|
+
// Act
|
|
3995
|
+
var act = async () => await _sut.UpdateAsync(id, "new_code");
|
|
3996
|
+
|
|
3997
|
+
// Assert
|
|
3998
|
+
await act.Should().ThrowAsync<InvalidOperationException>()
|
|
3999
|
+
.WithMessage("*not found*");
|
|
4000
|
+
}
|
|
4001
|
+
|
|
4002
|
+
[Fact]
|
|
4003
|
+
public async Task DeleteAsync_WhenEntityNotFound_ShouldReturnFalse()
|
|
4004
|
+
{
|
|
4005
|
+
// Arrange
|
|
4006
|
+
var id = Guid.NewGuid();
|
|
4007
|
+
_repositoryMock
|
|
4008
|
+
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
|
4009
|
+
.ReturnsAsync(({{name}}?)null);
|
|
4010
|
+
|
|
4011
|
+
// Act
|
|
4012
|
+
var result = await _sut.DeleteAsync(id);
|
|
4013
|
+
|
|
4014
|
+
// Assert
|
|
4015
|
+
result.Should().BeFalse();
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
#endregion
|
|
4019
|
+
|
|
4020
|
+
{{#if includeEdgeCases}}
|
|
4021
|
+
#region Edge Cases
|
|
4022
|
+
|
|
4023
|
+
[Fact]
|
|
4024
|
+
public async Task CreateAsync_WithEmptyCode_ShouldStillCreate()
|
|
4025
|
+
{
|
|
4026
|
+
// Arrange
|
|
4027
|
+
_repositoryMock
|
|
4028
|
+
.Setup(r => r.AddAsync(It.IsAny<{{name}}>(), It.IsAny<CancellationToken>()))
|
|
4029
|
+
.ReturnsAsync(({{name}} e, CancellationToken _) => e);
|
|
4030
|
+
|
|
4031
|
+
// Act
|
|
4032
|
+
var result = await _sut.CreateAsync({{#unless isSystemEntity}}_tenantId, {{/unless}}"");
|
|
4033
|
+
|
|
4034
|
+
// Assert
|
|
4035
|
+
result.Should().NotBeNull();
|
|
4036
|
+
result.Code.Should().BeEmpty();
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
[Fact]
|
|
4040
|
+
public async Task GetAllAsync_WhenNoEntities_ShouldReturnEmptyList()
|
|
4041
|
+
{
|
|
4042
|
+
// Arrange
|
|
4043
|
+
_repositoryMock
|
|
4044
|
+
.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
|
|
4045
|
+
.ReturnsAsync(new List<{{name}}>());
|
|
4046
|
+
|
|
4047
|
+
// Act
|
|
4048
|
+
var result = await _sut.GetAllAsync();
|
|
4049
|
+
|
|
4050
|
+
// Assert
|
|
4051
|
+
result.Should().BeEmpty();
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
#endregion
|
|
4055
|
+
{{/if}}
|
|
4056
|
+
}
|
|
4057
|
+
`;
|
|
4058
|
+
var controllerTestTemplate = `using System;
|
|
4059
|
+
using System.Collections.Generic;
|
|
4060
|
+
using System.Net;
|
|
4061
|
+
using System.Net.Http;
|
|
4062
|
+
using System.Net.Http.Json;
|
|
4063
|
+
using System.Threading.Tasks;
|
|
4064
|
+
using FluentAssertions;
|
|
4065
|
+
using Microsoft.AspNetCore.Mvc.Testing;
|
|
4066
|
+
using Microsoft.Extensions.DependencyInjection;
|
|
4067
|
+
using Moq;
|
|
4068
|
+
using Xunit;
|
|
4069
|
+
using {{apiNamespace}};
|
|
4070
|
+
using {{applicationNamespace}}.Services;
|
|
4071
|
+
using {{domainNamespace}};
|
|
4072
|
+
|
|
4073
|
+
namespace {{testNamespace}}.Integration.Controllers;
|
|
4074
|
+
|
|
4075
|
+
/// <summary>
|
|
4076
|
+
/// Integration tests for {{name}}Controller
|
|
4077
|
+
/// Tests: HTTP status codes, Authorization, Validation, CRUD operations
|
|
4078
|
+
/// </summary>
|
|
4079
|
+
public class {{name}}ControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
|
4080
|
+
{
|
|
4081
|
+
private readonly WebApplicationFactory<Program> _factory;
|
|
4082
|
+
private readonly HttpClient _client;
|
|
4083
|
+
private readonly Mock<I{{name}}Service> _serviceMock;
|
|
4084
|
+
{{#unless isSystemEntity}}
|
|
4085
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
4086
|
+
{{/unless}}
|
|
4087
|
+
|
|
4088
|
+
public {{name}}ControllerTests(WebApplicationFactory<Program> factory)
|
|
4089
|
+
{
|
|
4090
|
+
_serviceMock = new Mock<I{{name}}Service>();
|
|
4091
|
+
|
|
4092
|
+
_factory = factory.WithWebHostBuilder(builder =>
|
|
4093
|
+
{
|
|
4094
|
+
builder.ConfigureServices(services =>
|
|
4095
|
+
{
|
|
4096
|
+
// Replace the real service with our mock
|
|
4097
|
+
var descriptor = services.SingleOrDefault(
|
|
4098
|
+
d => d.ServiceType == typeof(I{{name}}Service));
|
|
4099
|
+
|
|
4100
|
+
if (descriptor != null)
|
|
4101
|
+
{
|
|
4102
|
+
services.Remove(descriptor);
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
services.AddScoped(_ => _serviceMock.Object);
|
|
4106
|
+
});
|
|
4107
|
+
});
|
|
4108
|
+
|
|
4109
|
+
_client = _factory.CreateClient();
|
|
4110
|
+
{{#unless isSystemEntity}}
|
|
4111
|
+
_client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4112
|
+
{{/unless}}
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
#region GET Tests
|
|
4116
|
+
|
|
4117
|
+
[Fact]
|
|
4118
|
+
public async Task GetAll_WhenAuthorized_ShouldReturn200()
|
|
4119
|
+
{
|
|
4120
|
+
// Arrange
|
|
4121
|
+
var entities = new List<{{name}}>
|
|
4122
|
+
{
|
|
4123
|
+
{{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"entity1"),
|
|
4124
|
+
{{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"entity2"),
|
|
4125
|
+
};
|
|
4126
|
+
_serviceMock
|
|
4127
|
+
.Setup(s => s.GetAllAsync({{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4128
|
+
.ReturnsAsync(entities);
|
|
4129
|
+
|
|
4130
|
+
// Act
|
|
4131
|
+
var response = await _client.GetAsync("/api/{{nameLower}}");
|
|
4132
|
+
|
|
4133
|
+
// Assert
|
|
4134
|
+
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
[Fact]
|
|
4138
|
+
public async Task GetById_WhenEntityExists_ShouldReturn200()
|
|
4139
|
+
{
|
|
4140
|
+
// Arrange
|
|
4141
|
+
var id = Guid.NewGuid();
|
|
4142
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4143
|
+
_serviceMock
|
|
4144
|
+
.Setup(s => s.GetByIdAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4145
|
+
.ReturnsAsync(entity);
|
|
4146
|
+
|
|
4147
|
+
// Act
|
|
4148
|
+
var response = await _client.GetAsync($"/api/{{nameLower}}/{id}");
|
|
4149
|
+
|
|
4150
|
+
// Assert
|
|
4151
|
+
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
[Fact]
|
|
4155
|
+
public async Task GetById_WhenNotFound_ShouldReturn404()
|
|
4156
|
+
{
|
|
4157
|
+
// Arrange
|
|
4158
|
+
var id = Guid.NewGuid();
|
|
4159
|
+
_serviceMock
|
|
4160
|
+
.Setup(s => s.GetByIdAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4161
|
+
.ReturnsAsync(({{name}}?)null);
|
|
4162
|
+
|
|
4163
|
+
// Act
|
|
4164
|
+
var response = await _client.GetAsync($"/api/{{nameLower}}/{id}");
|
|
4165
|
+
|
|
4166
|
+
// Assert
|
|
4167
|
+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
#endregion
|
|
4171
|
+
|
|
4172
|
+
#region POST Tests
|
|
4173
|
+
|
|
4174
|
+
[Fact]
|
|
4175
|
+
public async Task Create_WhenValidData_ShouldReturn201()
|
|
4176
|
+
{
|
|
4177
|
+
// Arrange
|
|
4178
|
+
var request = new { Code = "new_entity" };
|
|
4179
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}request.Code);
|
|
4180
|
+
_serviceMock
|
|
4181
|
+
.Setup(s => s.CreateAsync({{#unless isSystemEntity}}_tenantId, {{/unless}}request.Code, default))
|
|
4182
|
+
.ReturnsAsync(entity);
|
|
4183
|
+
|
|
4184
|
+
// Act
|
|
4185
|
+
var response = await _client.PostAsJsonAsync("/api/{{nameLower}}", request);
|
|
4186
|
+
|
|
4187
|
+
// Assert
|
|
4188
|
+
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
{{#if includeValidation}}
|
|
4192
|
+
[Fact]
|
|
4193
|
+
public async Task Create_WhenInvalidData_ShouldReturn400()
|
|
4194
|
+
{
|
|
4195
|
+
// Arrange
|
|
4196
|
+
var request = new { Code = (string?)null };
|
|
4197
|
+
|
|
4198
|
+
// Act
|
|
4199
|
+
var response = await _client.PostAsJsonAsync("/api/{{nameLower}}", request);
|
|
4200
|
+
|
|
4201
|
+
// Assert
|
|
4202
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4203
|
+
}
|
|
4204
|
+
{{/if}}
|
|
4205
|
+
|
|
4206
|
+
#endregion
|
|
4207
|
+
|
|
4208
|
+
#region PUT Tests
|
|
4209
|
+
|
|
4210
|
+
[Fact]
|
|
4211
|
+
public async Task Update_WhenEntityExists_ShouldReturn200()
|
|
4212
|
+
{
|
|
4213
|
+
// Arrange
|
|
4214
|
+
var id = Guid.NewGuid();
|
|
4215
|
+
var request = new { Code = "updated_code" };
|
|
4216
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4217
|
+
_serviceMock
|
|
4218
|
+
.Setup(s => s.GetByIdAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4219
|
+
.ReturnsAsync(entity);
|
|
4220
|
+
_serviceMock
|
|
4221
|
+
.Setup(s => s.UpdateAsync(id, request.Code, default))
|
|
4222
|
+
.ReturnsAsync(entity);
|
|
4223
|
+
|
|
4224
|
+
// Act
|
|
4225
|
+
var response = await _client.PutAsJsonAsync($"/api/{{nameLower}}/{id}", request);
|
|
4226
|
+
|
|
4227
|
+
// Assert
|
|
4228
|
+
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
[Fact]
|
|
4232
|
+
public async Task Update_WhenNotFound_ShouldReturn404()
|
|
4233
|
+
{
|
|
4234
|
+
// Arrange
|
|
4235
|
+
var id = Guid.NewGuid();
|
|
4236
|
+
var request = new { Code = "updated_code" };
|
|
4237
|
+
_serviceMock
|
|
4238
|
+
.Setup(s => s.GetByIdAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4239
|
+
.ReturnsAsync(({{name}}?)null);
|
|
4240
|
+
|
|
4241
|
+
// Act
|
|
4242
|
+
var response = await _client.PutAsJsonAsync($"/api/{{nameLower}}/{id}", request);
|
|
4243
|
+
|
|
4244
|
+
// Assert
|
|
4245
|
+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
#endregion
|
|
4249
|
+
|
|
4250
|
+
#region DELETE Tests
|
|
4251
|
+
|
|
4252
|
+
[Fact]
|
|
4253
|
+
public async Task Delete_WhenEntityExists_ShouldReturn204()
|
|
4254
|
+
{
|
|
4255
|
+
// Arrange
|
|
4256
|
+
var id = Guid.NewGuid();
|
|
4257
|
+
_serviceMock
|
|
4258
|
+
.Setup(s => s.DeleteAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4259
|
+
.ReturnsAsync(true);
|
|
4260
|
+
|
|
4261
|
+
// Act
|
|
4262
|
+
var response = await _client.DeleteAsync($"/api/{{nameLower}}/{id}");
|
|
4263
|
+
|
|
4264
|
+
// Assert
|
|
4265
|
+
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
[Fact]
|
|
4269
|
+
public async Task Delete_WhenNotFound_ShouldReturn404()
|
|
4270
|
+
{
|
|
4271
|
+
// Arrange
|
|
4272
|
+
var id = Guid.NewGuid();
|
|
4273
|
+
_serviceMock
|
|
4274
|
+
.Setup(s => s.DeleteAsync(id, {{#unless isSystemEntity}}_tenantId, {{/unless}}default))
|
|
4275
|
+
.ReturnsAsync(false);
|
|
4276
|
+
|
|
4277
|
+
// Act
|
|
4278
|
+
var response = await _client.DeleteAsync($"/api/{{nameLower}}/{id}");
|
|
4279
|
+
|
|
4280
|
+
// Assert
|
|
4281
|
+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
#endregion
|
|
4285
|
+
|
|
4286
|
+
{{#if includeAuthorization}}
|
|
4287
|
+
#region Authorization Tests
|
|
4288
|
+
|
|
4289
|
+
[Fact]
|
|
4290
|
+
public async Task GetAll_WhenUnauthorized_ShouldReturn401()
|
|
4291
|
+
{
|
|
4292
|
+
// Arrange
|
|
4293
|
+
var client = _factory.CreateClient();
|
|
4294
|
+
// Don't add auth header
|
|
4295
|
+
|
|
4296
|
+
// Act
|
|
4297
|
+
var response = await client.GetAsync("/api/{{nameLower}}");
|
|
4298
|
+
|
|
4299
|
+
// Assert
|
|
4300
|
+
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4303
|
+
{{#unless isSystemEntity}}
|
|
4304
|
+
[Fact]
|
|
4305
|
+
public async Task GetById_WhenDifferentTenant_ShouldReturn403()
|
|
4306
|
+
{
|
|
4307
|
+
// Arrange
|
|
4308
|
+
var id = Guid.NewGuid();
|
|
4309
|
+
var client = _factory.CreateClient();
|
|
4310
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", Guid.NewGuid().ToString());
|
|
4311
|
+
|
|
4312
|
+
_serviceMock
|
|
4313
|
+
.Setup(s => s.GetByIdAsync(id, It.IsAny<Guid>(), default))
|
|
4314
|
+
.ReturnsAsync(({{name}}?)null);
|
|
4315
|
+
|
|
4316
|
+
// Act
|
|
4317
|
+
var response = await client.GetAsync($"/api/{{nameLower}}/{id}");
|
|
4318
|
+
|
|
4319
|
+
// Assert
|
|
4320
|
+
response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.NotFound);
|
|
4321
|
+
}
|
|
4322
|
+
{{/unless}}
|
|
4323
|
+
|
|
4324
|
+
#endregion
|
|
4325
|
+
{{/if}}
|
|
4326
|
+
}
|
|
4327
|
+
`;
|
|
4328
|
+
var validatorTestTemplate = `using FluentAssertions;
|
|
4329
|
+
using FluentValidation.TestHelper;
|
|
4330
|
+
using Xunit;
|
|
4331
|
+
using {{applicationNamespace}}.DTOs;
|
|
4332
|
+
using {{applicationNamespace}}.Validators;
|
|
4333
|
+
|
|
4334
|
+
namespace {{testNamespace}}.Unit.Validators;
|
|
4335
|
+
|
|
4336
|
+
/// <summary>
|
|
4337
|
+
/// Unit tests for {{name}} validators
|
|
4338
|
+
/// Tests: Validation rules, Error messages
|
|
4339
|
+
/// </summary>
|
|
4340
|
+
public class {{name}}ValidatorTests
|
|
4341
|
+
{
|
|
4342
|
+
private readonly Create{{name}}DtoValidator _createValidator;
|
|
4343
|
+
private readonly Update{{name}}DtoValidator _updateValidator;
|
|
4344
|
+
|
|
4345
|
+
public {{name}}ValidatorTests()
|
|
4346
|
+
{
|
|
4347
|
+
_createValidator = new Create{{name}}DtoValidator();
|
|
4348
|
+
_updateValidator = new Update{{name}}DtoValidator();
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
#region Create Validator Tests
|
|
4352
|
+
|
|
4353
|
+
[Fact]
|
|
4354
|
+
public void CreateValidator_WhenCodeIsEmpty_ShouldHaveError()
|
|
4355
|
+
{
|
|
4356
|
+
// Arrange
|
|
4357
|
+
var dto = new Create{{name}}Dto { Code = string.Empty };
|
|
4358
|
+
|
|
4359
|
+
// Act
|
|
4360
|
+
var result = _createValidator.TestValidate(dto);
|
|
4361
|
+
|
|
4362
|
+
// Assert
|
|
4363
|
+
result.ShouldHaveValidationErrorFor(x => x.Code)
|
|
4364
|
+
.WithErrorMessage("*required*");
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
[Fact]
|
|
4368
|
+
public void CreateValidator_WhenCodeIsTooLong_ShouldHaveError()
|
|
4369
|
+
{
|
|
4370
|
+
// Arrange
|
|
4371
|
+
var dto = new Create{{name}}Dto { Code = new string('a', 101) };
|
|
4372
|
+
|
|
4373
|
+
// Act
|
|
4374
|
+
var result = _createValidator.TestValidate(dto);
|
|
4375
|
+
|
|
4376
|
+
// Assert
|
|
4377
|
+
result.ShouldHaveValidationErrorFor(x => x.Code)
|
|
4378
|
+
.WithErrorMessage("*100 characters*");
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
[Fact]
|
|
4382
|
+
public void CreateValidator_WhenValidData_ShouldNotHaveErrors()
|
|
4383
|
+
{
|
|
4384
|
+
// Arrange
|
|
4385
|
+
var dto = new Create{{name}}Dto { Code = "valid_code" };
|
|
4386
|
+
|
|
4387
|
+
// Act
|
|
4388
|
+
var result = _createValidator.TestValidate(dto);
|
|
4389
|
+
|
|
4390
|
+
// Assert
|
|
4391
|
+
result.ShouldNotHaveAnyValidationErrors();
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
#endregion
|
|
4395
|
+
|
|
4396
|
+
#region Update Validator Tests
|
|
4397
|
+
|
|
4398
|
+
[Fact]
|
|
4399
|
+
public void UpdateValidator_WhenCodeIsEmpty_ShouldHaveError()
|
|
4400
|
+
{
|
|
4401
|
+
// Arrange
|
|
4402
|
+
var dto = new Update{{name}}Dto { Code = string.Empty };
|
|
4403
|
+
|
|
4404
|
+
// Act
|
|
4405
|
+
var result = _updateValidator.TestValidate(dto);
|
|
4406
|
+
|
|
4407
|
+
// Assert
|
|
4408
|
+
result.ShouldHaveValidationErrorFor(x => x.Code);
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
[Fact]
|
|
4412
|
+
public void UpdateValidator_WhenValidData_ShouldNotHaveErrors()
|
|
4413
|
+
{
|
|
4414
|
+
// Arrange
|
|
4415
|
+
var dto = new Update{{name}}Dto { Code = "updated_code" };
|
|
4416
|
+
|
|
4417
|
+
// Act
|
|
4418
|
+
var result = _updateValidator.TestValidate(dto);
|
|
4419
|
+
|
|
4420
|
+
// Assert
|
|
4421
|
+
result.ShouldNotHaveAnyValidationErrors();
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
#endregion
|
|
4425
|
+
|
|
4426
|
+
{{#if includeEdgeCases}}
|
|
4427
|
+
#region Edge Cases
|
|
4428
|
+
|
|
4429
|
+
[Theory]
|
|
4430
|
+
[InlineData("test-code")]
|
|
4431
|
+
[InlineData("test_code")]
|
|
4432
|
+
[InlineData("test.code")]
|
|
4433
|
+
public void CreateValidator_WhenCodeHasSpecialCharacters_ShouldPass(string code)
|
|
4434
|
+
{
|
|
4435
|
+
// Arrange
|
|
4436
|
+
var dto = new Create{{name}}Dto { Code = code };
|
|
4437
|
+
|
|
4438
|
+
// Act
|
|
4439
|
+
var result = _createValidator.TestValidate(dto);
|
|
4440
|
+
|
|
4441
|
+
// Assert
|
|
4442
|
+
result.ShouldNotHaveValidationErrorFor(x => x.Code);
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
[Theory]
|
|
4446
|
+
[InlineData(" leading")]
|
|
4447
|
+
[InlineData("trailing ")]
|
|
4448
|
+
[InlineData(" both ")]
|
|
4449
|
+
public void CreateValidator_WhenCodeHasWhitespace_ShouldTrimAndValidate(string code)
|
|
4450
|
+
{
|
|
4451
|
+
// Arrange
|
|
4452
|
+
var dto = new Create{{name}}Dto { Code = code };
|
|
4453
|
+
|
|
4454
|
+
// Act
|
|
4455
|
+
var result = _createValidator.TestValidate(dto);
|
|
4456
|
+
|
|
4457
|
+
// Assert
|
|
4458
|
+
// Depending on implementation, may pass or fail
|
|
4459
|
+
// This test documents the expected behavior
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
#endregion
|
|
4463
|
+
{{/if}}
|
|
4464
|
+
}
|
|
4465
|
+
`;
|
|
4466
|
+
var repositoryTestTemplate = `using System;
|
|
4467
|
+
using System.Collections.Generic;
|
|
4468
|
+
using System.Linq;
|
|
4469
|
+
using System.Threading.Tasks;
|
|
4470
|
+
using FluentAssertions;
|
|
4471
|
+
using Microsoft.EntityFrameworkCore;
|
|
4472
|
+
using Xunit;
|
|
4473
|
+
using {{infrastructureNamespace}}.Persistence;
|
|
4474
|
+
using {{infrastructureNamespace}}.Repositories;
|
|
4475
|
+
using {{domainNamespace}};
|
|
4476
|
+
|
|
4477
|
+
namespace {{testNamespace}}.Integration.Repositories;
|
|
4478
|
+
|
|
4479
|
+
/// <summary>
|
|
4480
|
+
/// Integration tests for {{name}}Repository
|
|
4481
|
+
/// Tests: CRUD with DbContext, Queries, Tenant scope
|
|
4482
|
+
/// </summary>
|
|
4483
|
+
public class {{name}}RepositoryTests : IDisposable
|
|
4484
|
+
{
|
|
4485
|
+
private readonly ApplicationDbContext _context;
|
|
4486
|
+
private readonly {{name}}Repository _repository;
|
|
4487
|
+
{{#unless isSystemEntity}}
|
|
4488
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
4489
|
+
{{/unless}}
|
|
4490
|
+
|
|
4491
|
+
public {{name}}RepositoryTests()
|
|
4492
|
+
{
|
|
4493
|
+
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
4494
|
+
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
4495
|
+
.Options;
|
|
4496
|
+
|
|
4497
|
+
_context = new ApplicationDbContext(options);
|
|
4498
|
+
_repository = new {{name}}Repository(_context);
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
public void Dispose()
|
|
4502
|
+
{
|
|
4503
|
+
_context.Dispose();
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
#region GetByIdAsync Tests
|
|
4507
|
+
|
|
4508
|
+
[Fact]
|
|
4509
|
+
public async Task GetByIdAsync_WhenEntityExists_ShouldReturnEntity()
|
|
4510
|
+
{
|
|
4511
|
+
// Arrange
|
|
4512
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4513
|
+
await _context.Set<{{name}}>().AddAsync(entity);
|
|
4514
|
+
await _context.SaveChangesAsync();
|
|
4515
|
+
|
|
4516
|
+
// Act
|
|
4517
|
+
var result = await _repository.GetByIdAsync(entity.Id);
|
|
4518
|
+
|
|
4519
|
+
// Assert
|
|
4520
|
+
result.Should().NotBeNull();
|
|
4521
|
+
result!.Id.Should().Be(entity.Id);
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
[Fact]
|
|
4525
|
+
public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull()
|
|
4526
|
+
{
|
|
4527
|
+
// Arrange
|
|
4528
|
+
var id = Guid.NewGuid();
|
|
4529
|
+
|
|
4530
|
+
// Act
|
|
4531
|
+
var result = await _repository.GetByIdAsync(id);
|
|
4532
|
+
|
|
4533
|
+
// Assert
|
|
4534
|
+
result.Should().BeNull();
|
|
4535
|
+
}
|
|
4536
|
+
|
|
4537
|
+
#endregion
|
|
4538
|
+
|
|
4539
|
+
#region AddAsync Tests
|
|
4540
|
+
|
|
4541
|
+
[Fact]
|
|
4542
|
+
public async Task AddAsync_ShouldPersistEntity()
|
|
4543
|
+
{
|
|
4544
|
+
// Arrange
|
|
4545
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4546
|
+
|
|
4547
|
+
// Act
|
|
4548
|
+
var result = await _repository.AddAsync(entity);
|
|
4549
|
+
await _context.SaveChangesAsync();
|
|
4550
|
+
|
|
4551
|
+
// Assert
|
|
4552
|
+
result.Should().NotBeNull();
|
|
4553
|
+
var persisted = await _context.Set<{{name}}>().FindAsync(entity.Id);
|
|
4554
|
+
persisted.Should().NotBeNull();
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
#endregion
|
|
4558
|
+
|
|
4559
|
+
#region UpdateAsync Tests
|
|
4560
|
+
|
|
4561
|
+
[Fact]
|
|
4562
|
+
public async Task UpdateAsync_ShouldUpdateEntity()
|
|
4563
|
+
{
|
|
4564
|
+
// Arrange
|
|
4565
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"original");
|
|
4566
|
+
await _context.Set<{{name}}>().AddAsync(entity);
|
|
4567
|
+
await _context.SaveChangesAsync();
|
|
4568
|
+
|
|
4569
|
+
// Act
|
|
4570
|
+
entity.Update("updater");
|
|
4571
|
+
await _repository.UpdateAsync(entity);
|
|
4572
|
+
await _context.SaveChangesAsync();
|
|
4573
|
+
|
|
4574
|
+
// Assert
|
|
4575
|
+
var updated = await _context.Set<{{name}}>().FindAsync(entity.Id);
|
|
4576
|
+
updated!.UpdatedBy.Should().Be("updater");
|
|
4577
|
+
}
|
|
4578
|
+
|
|
4579
|
+
#endregion
|
|
4580
|
+
|
|
4581
|
+
#region DeleteAsync Tests
|
|
4582
|
+
|
|
4583
|
+
[Fact]
|
|
4584
|
+
public async Task DeleteAsync_ShouldRemoveEntity()
|
|
4585
|
+
{
|
|
4586
|
+
// Arrange
|
|
4587
|
+
var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"test");
|
|
4588
|
+
await _context.Set<{{name}}>().AddAsync(entity);
|
|
4589
|
+
await _context.SaveChangesAsync();
|
|
4590
|
+
|
|
4591
|
+
// Act
|
|
4592
|
+
await _repository.DeleteAsync(entity);
|
|
4593
|
+
await _context.SaveChangesAsync();
|
|
4594
|
+
|
|
4595
|
+
// Assert
|
|
4596
|
+
var deleted = await _context.Set<{{name}}>().FindAsync(entity.Id);
|
|
4597
|
+
deleted.Should().BeNull();
|
|
4598
|
+
}
|
|
4599
|
+
|
|
4600
|
+
#endregion
|
|
4601
|
+
|
|
4602
|
+
{{#unless isSystemEntity}}
|
|
4603
|
+
#region Tenant Isolation Tests
|
|
4604
|
+
|
|
4605
|
+
[Fact]
|
|
4606
|
+
public async Task GetAllByTenantAsync_ShouldOnlyReturnTenantEntities()
|
|
4607
|
+
{
|
|
4608
|
+
// Arrange
|
|
4609
|
+
var otherTenantId = Guid.NewGuid();
|
|
4610
|
+
await _context.Set<{{name}}>().AddRangeAsync(
|
|
4611
|
+
{{name}}.Create(_tenantId, "entity1"),
|
|
4612
|
+
{{name}}.Create(_tenantId, "entity2"),
|
|
4613
|
+
{{name}}.Create(otherTenantId, "other_tenant_entity")
|
|
4614
|
+
);
|
|
4615
|
+
await _context.SaveChangesAsync();
|
|
4616
|
+
|
|
4617
|
+
// Act
|
|
4618
|
+
var result = await _repository.GetAllByTenantAsync(_tenantId);
|
|
4619
|
+
|
|
4620
|
+
// Assert
|
|
4621
|
+
result.Should().HaveCount(2);
|
|
4622
|
+
result.Should().OnlyContain(e => e.TenantId == _tenantId);
|
|
4623
|
+
}
|
|
4624
|
+
|
|
4625
|
+
[Fact]
|
|
4626
|
+
public async Task ExistsAsync_WhenEntityInDifferentTenant_ShouldReturnFalse()
|
|
4627
|
+
{
|
|
4628
|
+
// Arrange
|
|
4629
|
+
var otherTenantId = Guid.NewGuid();
|
|
4630
|
+
var entity = {{name}}.Create(otherTenantId, "test");
|
|
4631
|
+
await _context.Set<{{name}}>().AddAsync(entity);
|
|
4632
|
+
await _context.SaveChangesAsync();
|
|
4633
|
+
|
|
4634
|
+
// Act
|
|
4635
|
+
var result = await _repository.ExistsAsync(entity.Id, _tenantId);
|
|
4636
|
+
|
|
4637
|
+
// Assert
|
|
4638
|
+
result.Should().BeFalse();
|
|
4639
|
+
}
|
|
4640
|
+
|
|
4641
|
+
#endregion
|
|
4642
|
+
{{/unless}}
|
|
4643
|
+
|
|
4644
|
+
{{#if includeSoftDelete}}
|
|
4645
|
+
#region Soft Delete Tests
|
|
4646
|
+
|
|
4647
|
+
[Fact]
|
|
4648
|
+
public async Task GetAllAsync_ShouldExcludeSoftDeletedEntities()
|
|
4649
|
+
{
|
|
4650
|
+
// Arrange
|
|
4651
|
+
var activeEntity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"active");
|
|
4652
|
+
var deletedEntity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"deleted");
|
|
4653
|
+
deletedEntity.SoftDelete("deleter");
|
|
4654
|
+
|
|
4655
|
+
await _context.Set<{{name}}>().AddRangeAsync(activeEntity, deletedEntity);
|
|
4656
|
+
await _context.SaveChangesAsync();
|
|
4657
|
+
|
|
4658
|
+
// Act
|
|
4659
|
+
var result = await _repository.GetAllAsync();
|
|
4660
|
+
|
|
4661
|
+
// Assert
|
|
4662
|
+
result.Should().ContainSingle();
|
|
4663
|
+
result.First().Code.Should().Be("active");
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
[Fact]
|
|
4667
|
+
public async Task GetAllIncludingDeletedAsync_ShouldIncludeSoftDeletedEntities()
|
|
4668
|
+
{
|
|
4669
|
+
// Arrange
|
|
4670
|
+
var activeEntity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"active");
|
|
4671
|
+
var deletedEntity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}"deleted");
|
|
4672
|
+
deletedEntity.SoftDelete("deleter");
|
|
4673
|
+
|
|
4674
|
+
await _context.Set<{{name}}>().AddRangeAsync(activeEntity, deletedEntity);
|
|
4675
|
+
await _context.SaveChangesAsync();
|
|
4676
|
+
|
|
4677
|
+
// Act
|
|
4678
|
+
var result = await _repository.GetAllIncludingDeletedAsync();
|
|
4679
|
+
|
|
4680
|
+
// Assert
|
|
4681
|
+
result.Should().HaveCount(2);
|
|
4682
|
+
}
|
|
4683
|
+
|
|
4684
|
+
#endregion
|
|
4685
|
+
{{/if}}
|
|
4686
|
+
}
|
|
4687
|
+
`;
|
|
4688
|
+
var securityTestTemplate = `using System;
|
|
4689
|
+
using System.Net;
|
|
4690
|
+
using System.Net.Http;
|
|
4691
|
+
using System.Threading.Tasks;
|
|
4692
|
+
using FluentAssertions;
|
|
4693
|
+
using Microsoft.AspNetCore.Mvc.Testing;
|
|
4694
|
+
using Xunit;
|
|
4695
|
+
using {{apiNamespace}};
|
|
4696
|
+
|
|
4697
|
+
namespace {{testNamespace}}.Security;
|
|
4698
|
+
|
|
4699
|
+
/// <summary>
|
|
4700
|
+
/// Security tests for {{name}}
|
|
4701
|
+
/// Tests: Tenant isolation, Authorization, Input validation, Injection prevention
|
|
4702
|
+
/// </summary>
|
|
4703
|
+
public class {{name}}SecurityTests : IClassFixture<WebApplicationFactory<Program>>
|
|
4704
|
+
{
|
|
4705
|
+
private readonly WebApplicationFactory<Program> _factory;
|
|
4706
|
+
{{#unless isSystemEntity}}
|
|
4707
|
+
private readonly Guid _tenantId = Guid.NewGuid();
|
|
4708
|
+
{{/unless}}
|
|
4709
|
+
|
|
4710
|
+
public {{name}}SecurityTests(WebApplicationFactory<Program> factory)
|
|
4711
|
+
{
|
|
4712
|
+
_factory = factory;
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
{{#unless isSystemEntity}}
|
|
4716
|
+
#region Tenant Isolation Tests
|
|
4717
|
+
|
|
4718
|
+
[Fact]
|
|
4719
|
+
public async Task Request_WithoutTenantHeader_ShouldReturn400()
|
|
4720
|
+
{
|
|
4721
|
+
// Arrange
|
|
4722
|
+
var client = _factory.CreateClient();
|
|
4723
|
+
// Intentionally not adding X-Tenant-Id header
|
|
4724
|
+
|
|
4725
|
+
// Act
|
|
4726
|
+
var response = await client.GetAsync("/api/{{nameLower}}");
|
|
4727
|
+
|
|
4728
|
+
// Assert
|
|
4729
|
+
response.StatusCode.Should().BeOneOf(
|
|
4730
|
+
HttpStatusCode.BadRequest,
|
|
4731
|
+
HttpStatusCode.Unauthorized
|
|
4732
|
+
);
|
|
4733
|
+
}
|
|
4734
|
+
|
|
4735
|
+
[Fact]
|
|
4736
|
+
public async Task Request_WithInvalidTenantId_ShouldReturn400()
|
|
4737
|
+
{
|
|
4738
|
+
// Arrange
|
|
4739
|
+
var client = _factory.CreateClient();
|
|
4740
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", "invalid-guid");
|
|
4741
|
+
|
|
4742
|
+
// Act
|
|
4743
|
+
var response = await client.GetAsync("/api/{{nameLower}}");
|
|
4744
|
+
|
|
4745
|
+
// Assert
|
|
4746
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4747
|
+
}
|
|
4748
|
+
|
|
4749
|
+
[Fact]
|
|
4750
|
+
public async Task Request_WithEmptyTenantId_ShouldReturn400()
|
|
4751
|
+
{
|
|
4752
|
+
// Arrange
|
|
4753
|
+
var client = _factory.CreateClient();
|
|
4754
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", Guid.Empty.ToString());
|
|
4755
|
+
|
|
4756
|
+
// Act
|
|
4757
|
+
var response = await client.GetAsync("/api/{{nameLower}}");
|
|
4758
|
+
|
|
4759
|
+
// Assert
|
|
4760
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
#endregion
|
|
4764
|
+
{{/unless}}
|
|
4765
|
+
|
|
4766
|
+
#region Input Validation Security Tests
|
|
4767
|
+
|
|
4768
|
+
[Theory]
|
|
4769
|
+
[InlineData("<script>alert('xss')</script>")]
|
|
4770
|
+
[InlineData("'; DROP TABLE users; --")]
|
|
4771
|
+
[InlineData("{{'{{'}}constructor.prototype{{'}}'}}")]
|
|
4772
|
+
public async Task Create_WithMaliciousInput_ShouldSanitizeOrReject(string maliciousInput)
|
|
4773
|
+
{
|
|
4774
|
+
// Arrange
|
|
4775
|
+
var client = _factory.CreateClient();
|
|
4776
|
+
{{#unless isSystemEntity}}
|
|
4777
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4778
|
+
{{/unless}}
|
|
4779
|
+
var request = new { Code = maliciousInput };
|
|
4780
|
+
|
|
4781
|
+
// Act
|
|
4782
|
+
var response = await client.PostAsJsonAsync("/api/{{nameLower}}", request);
|
|
4783
|
+
|
|
4784
|
+
// Assert
|
|
4785
|
+
// Should either reject (400) or sanitize (201 with cleaned data)
|
|
4786
|
+
response.StatusCode.Should().BeOneOf(
|
|
4787
|
+
HttpStatusCode.BadRequest,
|
|
4788
|
+
HttpStatusCode.Created,
|
|
4789
|
+
HttpStatusCode.UnprocessableEntity
|
|
4790
|
+
);
|
|
4791
|
+
}
|
|
4792
|
+
|
|
4793
|
+
[Theory]
|
|
4794
|
+
[InlineData("../../../etc/passwd")]
|
|
4795
|
+
[InlineData("..\\\\..\\\\..\\\\windows\\\\system32")]
|
|
4796
|
+
[InlineData("....//....//....//etc/passwd")]
|
|
4797
|
+
public async Task Create_WithPathTraversalAttempt_ShouldReject(string pathTraversal)
|
|
4798
|
+
{
|
|
4799
|
+
// Arrange
|
|
4800
|
+
var client = _factory.CreateClient();
|
|
4801
|
+
{{#unless isSystemEntity}}
|
|
4802
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4803
|
+
{{/unless}}
|
|
4804
|
+
var request = new { Code = pathTraversal };
|
|
4805
|
+
|
|
4806
|
+
// Act
|
|
4807
|
+
var response = await client.PostAsJsonAsync("/api/{{nameLower}}", request);
|
|
4808
|
+
|
|
4809
|
+
// Assert
|
|
4810
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
#endregion
|
|
4814
|
+
|
|
4815
|
+
#region ID Manipulation Tests
|
|
4816
|
+
|
|
4817
|
+
[Fact]
|
|
4818
|
+
public async Task GetById_WithNegativeId_ShouldReturn400Or404()
|
|
4819
|
+
{
|
|
4820
|
+
// Arrange
|
|
4821
|
+
var client = _factory.CreateClient();
|
|
4822
|
+
{{#unless isSystemEntity}}
|
|
4823
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4824
|
+
{{/unless}}
|
|
4825
|
+
|
|
4826
|
+
// Act
|
|
4827
|
+
var response = await client.GetAsync("/api/{{nameLower}}/00000000-0000-0000-0000-000000000000");
|
|
4828
|
+
|
|
4829
|
+
// Assert
|
|
4830
|
+
response.StatusCode.Should().BeOneOf(
|
|
4831
|
+
HttpStatusCode.BadRequest,
|
|
4832
|
+
HttpStatusCode.NotFound
|
|
4833
|
+
);
|
|
4834
|
+
}
|
|
4835
|
+
|
|
4836
|
+
[Fact]
|
|
4837
|
+
public async Task GetById_WithMalformedGuid_ShouldReturn400()
|
|
4838
|
+
{
|
|
4839
|
+
// Arrange
|
|
4840
|
+
var client = _factory.CreateClient();
|
|
4841
|
+
{{#unless isSystemEntity}}
|
|
4842
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4843
|
+
{{/unless}}
|
|
4844
|
+
|
|
4845
|
+
// Act
|
|
4846
|
+
var response = await client.GetAsync("/api/{{nameLower}}/not-a-guid");
|
|
4847
|
+
|
|
4848
|
+
// Assert
|
|
4849
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
4850
|
+
}
|
|
4851
|
+
|
|
4852
|
+
#endregion
|
|
4853
|
+
|
|
4854
|
+
#region Rate Limiting Tests (if applicable)
|
|
4855
|
+
|
|
4856
|
+
[Fact]
|
|
4857
|
+
public async Task MultipleRapidRequests_ShouldNotCauseDenialOfService()
|
|
4858
|
+
{
|
|
4859
|
+
// Arrange
|
|
4860
|
+
var client = _factory.CreateClient();
|
|
4861
|
+
{{#unless isSystemEntity}}
|
|
4862
|
+
client.DefaultRequestHeaders.Add("X-Tenant-Id", _tenantId.ToString());
|
|
4863
|
+
{{/unless}}
|
|
4864
|
+
var tasks = new List<Task<HttpResponseMessage>>();
|
|
4865
|
+
|
|
4866
|
+
// Act
|
|
4867
|
+
for (int i = 0; i < 10; i++)
|
|
4868
|
+
{
|
|
4869
|
+
tasks.Add(client.GetAsync("/api/{{nameLower}}"));
|
|
4870
|
+
}
|
|
4871
|
+
var responses = await Task.WhenAll(tasks);
|
|
4872
|
+
|
|
4873
|
+
// Assert
|
|
4874
|
+
// Should handle without crashing (may return 429 if rate limiting is enabled)
|
|
4875
|
+
responses.Should().OnlyContain(r =>
|
|
4876
|
+
r.StatusCode == HttpStatusCode.OK ||
|
|
4877
|
+
r.StatusCode == HttpStatusCode.TooManyRequests);
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
#endregion
|
|
4881
|
+
}
|
|
4882
|
+
`;
|
|
4883
|
+
async function handleScaffoldTests(args, config) {
|
|
4884
|
+
const input = ScaffoldTestsInputSchema.parse(args);
|
|
4885
|
+
const dryRun = input.options?.dryRun || false;
|
|
4886
|
+
logger.info("Scaffolding tests", { target: input.target, name: input.name, dryRun });
|
|
4887
|
+
const structure = await findSmartStackStructure(config.smartstack.projectPath);
|
|
4888
|
+
const result = {
|
|
4889
|
+
success: true,
|
|
4890
|
+
files: [],
|
|
4891
|
+
instructions: []
|
|
4892
|
+
};
|
|
4893
|
+
const options = {
|
|
4894
|
+
includeEdgeCases: input.options?.includeEdgeCases ?? true,
|
|
4895
|
+
includeTenantIsolation: input.options?.includeTenantIsolation ?? true,
|
|
4896
|
+
includeSoftDelete: input.options?.includeSoftDelete ?? true,
|
|
4897
|
+
includeAudit: input.options?.includeAudit ?? true,
|
|
4898
|
+
includeValidation: input.options?.includeValidation ?? true,
|
|
4899
|
+
includeAuthorization: input.options?.includeAuthorization ?? false,
|
|
4900
|
+
isSystemEntity: input.options?.isSystemEntity ?? false
|
|
4901
|
+
};
|
|
4902
|
+
const testTypes = input.testTypes || ["unit"];
|
|
4903
|
+
try {
|
|
4904
|
+
switch (input.target) {
|
|
4905
|
+
case "entity":
|
|
4906
|
+
await scaffoldEntityTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4907
|
+
break;
|
|
4908
|
+
case "service":
|
|
4909
|
+
await scaffoldServiceTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4910
|
+
break;
|
|
4911
|
+
case "controller":
|
|
4912
|
+
await scaffoldControllerTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4913
|
+
break;
|
|
4914
|
+
case "validator":
|
|
4915
|
+
await scaffoldValidatorTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4916
|
+
break;
|
|
4917
|
+
case "repository":
|
|
4918
|
+
await scaffoldRepositoryTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4919
|
+
break;
|
|
4920
|
+
case "all":
|
|
4921
|
+
await scaffoldEntityTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4922
|
+
await scaffoldServiceTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4923
|
+
await scaffoldControllerTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4924
|
+
await scaffoldValidatorTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4925
|
+
await scaffoldRepositoryTests(input.name, options, testTypes, structure, config, result, dryRun);
|
|
4926
|
+
break;
|
|
4927
|
+
}
|
|
4928
|
+
} catch (error) {
|
|
4929
|
+
result.success = false;
|
|
4930
|
+
result.instructions.push(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
4931
|
+
}
|
|
4932
|
+
return formatTestResult(result, input.target, input.name, dryRun);
|
|
4933
|
+
}
|
|
4934
|
+
async function scaffoldEntityTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
4935
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
4936
|
+
const domainNamespace = config.conventions.namespaces.domain;
|
|
4937
|
+
const context = {
|
|
4938
|
+
name,
|
|
4939
|
+
testNamespace,
|
|
4940
|
+
domainNamespace,
|
|
4941
|
+
...options
|
|
4942
|
+
};
|
|
4943
|
+
if (testTypes.includes("unit")) {
|
|
4944
|
+
const content = Handlebars2.compile(entityTestTemplate)(context);
|
|
4945
|
+
const testPath = path10.join(structure.root, "Tests", "Unit", "Domain", `${name}Tests.cs`);
|
|
4946
|
+
validatePathSecurity(testPath, structure.root);
|
|
4947
|
+
if (!dryRun) {
|
|
4948
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
4949
|
+
await writeText(testPath, content);
|
|
4950
|
+
}
|
|
4951
|
+
result.files.push({
|
|
4952
|
+
path: path10.relative(structure.root, testPath),
|
|
4953
|
+
content,
|
|
4954
|
+
type: "created"
|
|
4955
|
+
});
|
|
4956
|
+
}
|
|
4957
|
+
if (testTypes.includes("security")) {
|
|
4958
|
+
const securityContent = Handlebars2.compile(securityTestTemplate)({
|
|
4959
|
+
...context,
|
|
4960
|
+
nameLower: name.charAt(0).toLowerCase() + name.slice(1),
|
|
4961
|
+
apiNamespace: config.conventions.namespaces.api
|
|
4962
|
+
});
|
|
4963
|
+
const securityPath = path10.join(structure.root, "Tests", "Security", `${name}SecurityTests.cs`);
|
|
4964
|
+
validatePathSecurity(securityPath, structure.root);
|
|
4965
|
+
if (!dryRun) {
|
|
4966
|
+
await ensureDirectory(path10.dirname(securityPath));
|
|
4967
|
+
await writeText(securityPath, securityContent);
|
|
4968
|
+
}
|
|
4969
|
+
result.files.push({
|
|
4970
|
+
path: path10.relative(structure.root, securityPath),
|
|
4971
|
+
content: securityContent,
|
|
4972
|
+
type: "created"
|
|
4973
|
+
});
|
|
4974
|
+
}
|
|
4975
|
+
result.instructions.push(`Add package reference: <PackageReference Include="FluentAssertions" Version="6.*" />`);
|
|
4976
|
+
result.instructions.push(`Add package reference: <PackageReference Include="xunit" Version="2.*" />`);
|
|
4977
|
+
}
|
|
4978
|
+
async function scaffoldServiceTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
4979
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
4980
|
+
const applicationNamespace = config.conventions.namespaces.application;
|
|
4981
|
+
const domainNamespace = config.conventions.namespaces.domain;
|
|
4982
|
+
const context = {
|
|
4983
|
+
name,
|
|
4984
|
+
testNamespace,
|
|
4985
|
+
applicationNamespace,
|
|
4986
|
+
domainNamespace,
|
|
4987
|
+
...options
|
|
4988
|
+
};
|
|
4989
|
+
if (testTypes.includes("unit")) {
|
|
4990
|
+
const content = Handlebars2.compile(serviceTestTemplate)(context);
|
|
4991
|
+
const testPath = path10.join(structure.root, "Tests", "Unit", "Services", `${name}ServiceTests.cs`);
|
|
4992
|
+
validatePathSecurity(testPath, structure.root);
|
|
4993
|
+
if (!dryRun) {
|
|
4994
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
4995
|
+
await writeText(testPath, content);
|
|
4996
|
+
}
|
|
4997
|
+
result.files.push({
|
|
4998
|
+
path: path10.relative(structure.root, testPath),
|
|
4999
|
+
content,
|
|
5000
|
+
type: "created"
|
|
5001
|
+
});
|
|
5002
|
+
}
|
|
5003
|
+
result.instructions.push(`Add package reference: <PackageReference Include="Moq" Version="4.*" />`);
|
|
5004
|
+
}
|
|
5005
|
+
async function scaffoldControllerTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
5006
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
5007
|
+
const applicationNamespace = config.conventions.namespaces.application;
|
|
5008
|
+
const domainNamespace = config.conventions.namespaces.domain;
|
|
5009
|
+
const apiNamespace = config.conventions.namespaces.api;
|
|
5010
|
+
const context = {
|
|
5011
|
+
name,
|
|
5012
|
+
nameLower: name.charAt(0).toLowerCase() + name.slice(1),
|
|
5013
|
+
testNamespace,
|
|
5014
|
+
applicationNamespace,
|
|
5015
|
+
domainNamespace,
|
|
5016
|
+
apiNamespace,
|
|
5017
|
+
...options
|
|
5018
|
+
};
|
|
5019
|
+
if (testTypes.includes("integration")) {
|
|
5020
|
+
const content = Handlebars2.compile(controllerTestTemplate)(context);
|
|
5021
|
+
const testPath = path10.join(structure.root, "Tests", "Integration", "Controllers", `${name}ControllerTests.cs`);
|
|
5022
|
+
validatePathSecurity(testPath, structure.root);
|
|
5023
|
+
if (!dryRun) {
|
|
5024
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
5025
|
+
await writeText(testPath, content);
|
|
5026
|
+
}
|
|
5027
|
+
result.files.push({
|
|
5028
|
+
path: path10.relative(structure.root, testPath),
|
|
5029
|
+
content,
|
|
5030
|
+
type: "created"
|
|
5031
|
+
});
|
|
5032
|
+
}
|
|
5033
|
+
if (testTypes.includes("security")) {
|
|
5034
|
+
const securityContent = Handlebars2.compile(securityTestTemplate)(context);
|
|
5035
|
+
const securityPath = path10.join(structure.root, "Tests", "Security", `${name}SecurityTests.cs`);
|
|
5036
|
+
validatePathSecurity(securityPath, structure.root);
|
|
5037
|
+
if (!dryRun) {
|
|
5038
|
+
await ensureDirectory(path10.dirname(securityPath));
|
|
5039
|
+
await writeText(securityPath, securityContent);
|
|
5040
|
+
}
|
|
5041
|
+
result.files.push({
|
|
5042
|
+
path: path10.relative(structure.root, securityPath),
|
|
5043
|
+
content: securityContent,
|
|
5044
|
+
type: "created"
|
|
5045
|
+
});
|
|
5046
|
+
}
|
|
5047
|
+
result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.*" />`);
|
|
5048
|
+
}
|
|
5049
|
+
async function scaffoldValidatorTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
5050
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
5051
|
+
const applicationNamespace = config.conventions.namespaces.application;
|
|
5052
|
+
const context = {
|
|
5053
|
+
name,
|
|
5054
|
+
testNamespace,
|
|
5055
|
+
applicationNamespace,
|
|
5056
|
+
...options
|
|
5057
|
+
};
|
|
5058
|
+
if (testTypes.includes("unit")) {
|
|
5059
|
+
const content = Handlebars2.compile(validatorTestTemplate)(context);
|
|
5060
|
+
const testPath = path10.join(structure.root, "Tests", "Unit", "Validators", `${name}ValidatorTests.cs`);
|
|
5061
|
+
validatePathSecurity(testPath, structure.root);
|
|
5062
|
+
if (!dryRun) {
|
|
5063
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
5064
|
+
await writeText(testPath, content);
|
|
5065
|
+
}
|
|
5066
|
+
result.files.push({
|
|
5067
|
+
path: path10.relative(structure.root, testPath),
|
|
5068
|
+
content,
|
|
5069
|
+
type: "created"
|
|
5070
|
+
});
|
|
5071
|
+
}
|
|
5072
|
+
result.instructions.push(`Add package reference: <PackageReference Include="FluentValidation.TestHelper" Version="11.*" />`);
|
|
5073
|
+
}
|
|
5074
|
+
async function scaffoldRepositoryTests(name, options, testTypes, structure, config, result, dryRun) {
|
|
5075
|
+
const testNamespace = `${config.conventions.namespaces.application}.Tests`;
|
|
5076
|
+
const infrastructureNamespace = config.conventions.namespaces.infrastructure;
|
|
5077
|
+
const domainNamespace = config.conventions.namespaces.domain;
|
|
5078
|
+
const context = {
|
|
5079
|
+
name,
|
|
5080
|
+
testNamespace,
|
|
5081
|
+
infrastructureNamespace,
|
|
5082
|
+
domainNamespace,
|
|
5083
|
+
...options
|
|
5084
|
+
};
|
|
5085
|
+
if (testTypes.includes("integration")) {
|
|
5086
|
+
const content = Handlebars2.compile(repositoryTestTemplate)(context);
|
|
5087
|
+
const testPath = path10.join(structure.root, "Tests", "Integration", "Repositories", `${name}RepositoryTests.cs`);
|
|
5088
|
+
validatePathSecurity(testPath, structure.root);
|
|
5089
|
+
if (!dryRun) {
|
|
5090
|
+
await ensureDirectory(path10.dirname(testPath));
|
|
5091
|
+
await writeText(testPath, content);
|
|
5092
|
+
}
|
|
5093
|
+
result.files.push({
|
|
5094
|
+
path: path10.relative(structure.root, testPath),
|
|
5095
|
+
content,
|
|
5096
|
+
type: "created"
|
|
5097
|
+
});
|
|
5098
|
+
}
|
|
5099
|
+
result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.*" />`);
|
|
5100
|
+
}
|
|
5101
|
+
function formatTestResult(result, _target, name, dryRun) {
|
|
5102
|
+
const lines = [];
|
|
5103
|
+
lines.push(`# Scaffold Tests: ${name}`);
|
|
5104
|
+
lines.push("");
|
|
5105
|
+
if (dryRun) {
|
|
5106
|
+
lines.push("> **DRY RUN MODE** - No files were written");
|
|
5107
|
+
lines.push("");
|
|
5108
|
+
}
|
|
5109
|
+
if (!result.success) {
|
|
5110
|
+
lines.push("## Error");
|
|
5111
|
+
lines.push("");
|
|
5112
|
+
lines.push(result.instructions.join("\n"));
|
|
5113
|
+
return lines.join("\n");
|
|
5114
|
+
}
|
|
5115
|
+
lines.push(`## Generated Test Files (${result.files.length})`);
|
|
5116
|
+
lines.push("");
|
|
5117
|
+
for (const file of result.files) {
|
|
5118
|
+
lines.push(`### \`${file.path}\``);
|
|
5119
|
+
lines.push("");
|
|
5120
|
+
lines.push("```csharp");
|
|
5121
|
+
lines.push(file.content);
|
|
5122
|
+
lines.push("```");
|
|
5123
|
+
lines.push("");
|
|
5124
|
+
}
|
|
5125
|
+
if (result.instructions.length > 0) {
|
|
5126
|
+
lines.push("## Next Steps");
|
|
5127
|
+
lines.push("");
|
|
5128
|
+
for (const instruction of result.instructions) {
|
|
5129
|
+
lines.push(`- ${instruction}`);
|
|
5130
|
+
}
|
|
5131
|
+
lines.push("");
|
|
5132
|
+
}
|
|
5133
|
+
lines.push("## Test Conventions Applied");
|
|
5134
|
+
lines.push("");
|
|
5135
|
+
lines.push("- **Naming**: `{MethodName}_When{Condition}_Should{ExpectedResult}`");
|
|
5136
|
+
lines.push("- **Pattern**: AAA (Arrange-Act-Assert)");
|
|
5137
|
+
lines.push("- **Assertions**: FluentAssertions (`.Should()`)");
|
|
5138
|
+
lines.push("- **Mocking**: Moq (`Mock<T>`)");
|
|
5139
|
+
lines.push("");
|
|
5140
|
+
return lines.join("\n");
|
|
5141
|
+
}
|
|
5142
|
+
|
|
5143
|
+
// src/tools/analyze-test-coverage.ts
|
|
5144
|
+
import path11 from "path";
|
|
5145
|
+
var analyzeTestCoverageTool = {
|
|
5146
|
+
name: "analyze_test_coverage",
|
|
5147
|
+
description: "Analyze test coverage for a SmartStack project. Identifies entities, services, and controllers without tests, calculates coverage ratios, and provides recommendations.",
|
|
5148
|
+
inputSchema: {
|
|
5149
|
+
type: "object",
|
|
5150
|
+
properties: {
|
|
5151
|
+
path: {
|
|
5152
|
+
type: "string",
|
|
5153
|
+
description: "Project path to analyze (default: configured SmartStack path)"
|
|
5154
|
+
},
|
|
5155
|
+
scope: {
|
|
5156
|
+
type: "string",
|
|
5157
|
+
enum: ["entity", "service", "controller", "all"],
|
|
5158
|
+
default: "all",
|
|
5159
|
+
description: "Scope of analysis"
|
|
5160
|
+
},
|
|
5161
|
+
outputFormat: {
|
|
5162
|
+
type: "string",
|
|
5163
|
+
enum: ["summary", "detailed", "json"],
|
|
5164
|
+
default: "summary",
|
|
5165
|
+
description: "Output format"
|
|
5166
|
+
},
|
|
5167
|
+
includeRecommendations: {
|
|
5168
|
+
type: "boolean",
|
|
5169
|
+
default: true,
|
|
5170
|
+
description: "Include recommendations for missing tests"
|
|
5171
|
+
}
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
};
|
|
5175
|
+
async function handleAnalyzeTestCoverage(args, config) {
|
|
5176
|
+
const input = AnalyzeTestCoverageInputSchema.parse(args);
|
|
5177
|
+
const projectPath = input.path || config.smartstack.projectPath;
|
|
5178
|
+
logger.info("Analyzing test coverage", { projectPath, scope: input.scope });
|
|
5179
|
+
const structure = await findSmartStackStructure(projectPath);
|
|
5180
|
+
const result = {
|
|
5181
|
+
summary: { total: 0, tested: 0, coverage: 0 },
|
|
5182
|
+
entities: { total: 0, tested: 0, coverage: 0, items: [], missingTests: [] },
|
|
5183
|
+
services: { total: 0, tested: 0, coverage: 0, items: [], missingTests: [] },
|
|
5184
|
+
controllers: { total: 0, tested: 0, coverage: 0, items: [], missingTests: [] },
|
|
5185
|
+
missing: [],
|
|
5186
|
+
recommendations: []
|
|
5187
|
+
};
|
|
5188
|
+
try {
|
|
5189
|
+
if (input.scope === "all" || input.scope === "entity") {
|
|
5190
|
+
await analyzeEntityCoverage(structure, result);
|
|
5191
|
+
}
|
|
5192
|
+
if (input.scope === "all" || input.scope === "service") {
|
|
5193
|
+
await analyzeServiceCoverage(structure, result);
|
|
5194
|
+
}
|
|
5195
|
+
if (input.scope === "all" || input.scope === "controller") {
|
|
5196
|
+
await analyzeControllerCoverage(structure, result);
|
|
5197
|
+
}
|
|
5198
|
+
result.summary.total = result.entities.total + result.services.total + result.controllers.total;
|
|
5199
|
+
result.summary.tested = result.entities.tested + result.services.tested + result.controllers.tested;
|
|
5200
|
+
result.summary.coverage = result.summary.total > 0 ? Math.round(result.summary.tested / result.summary.total * 100) : 0;
|
|
5201
|
+
if (input.includeRecommendations) {
|
|
5202
|
+
generateRecommendations(result);
|
|
5203
|
+
}
|
|
5204
|
+
} catch (error) {
|
|
5205
|
+
logger.error("Error analyzing test coverage", error);
|
|
5206
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
5207
|
+
}
|
|
5208
|
+
return formatCoverageResult(result, input.outputFormat);
|
|
5209
|
+
}
|
|
5210
|
+
async function analyzeEntityCoverage(structure, result) {
|
|
5211
|
+
if (!structure.domain) {
|
|
5212
|
+
result.recommendations.push("Domain layer not found - cannot analyze entity coverage");
|
|
5213
|
+
return;
|
|
5214
|
+
}
|
|
5215
|
+
const entityFiles = await findFiles("**/*.cs", { cwd: structure.domain });
|
|
5216
|
+
const entityNames = extractComponentNames(entityFiles, ["Entity", "Aggregate"]);
|
|
5217
|
+
const testPath = path11.join(structure.root, "Tests", "Unit", "Domain");
|
|
5218
|
+
let testFiles = [];
|
|
5219
|
+
try {
|
|
5220
|
+
testFiles = await findFiles("**/*Tests.cs", { cwd: testPath });
|
|
5221
|
+
} catch {
|
|
5222
|
+
}
|
|
5223
|
+
const testedEntities = extractTestedNames(testFiles);
|
|
5224
|
+
result.entities.total = entityNames.length;
|
|
5225
|
+
result.entities.items = entityNames;
|
|
5226
|
+
result.entities.tested = entityNames.filter((e) => testedEntities.includes(e)).length;
|
|
5227
|
+
result.entities.coverage = result.entities.total > 0 ? Math.round(result.entities.tested / result.entities.total * 100) : 0;
|
|
5228
|
+
result.entities.missingTests = entityNames.filter((e) => !testedEntities.includes(e));
|
|
5229
|
+
for (const name of result.entities.missingTests) {
|
|
5230
|
+
result.missing.push({
|
|
5231
|
+
name,
|
|
5232
|
+
type: "entity",
|
|
5233
|
+
priority: determinePriority(name, "entity"),
|
|
5234
|
+
recommendation: `Create unit tests for ${name} entity (factory methods, soft delete, audit trail)`,
|
|
5235
|
+
suggestedTestFile: `Tests/Unit/Domain/${name}Tests.cs`
|
|
5236
|
+
});
|
|
5237
|
+
}
|
|
5238
|
+
}
|
|
5239
|
+
async function analyzeServiceCoverage(structure, result) {
|
|
5240
|
+
if (!structure.application) {
|
|
5241
|
+
result.recommendations.push("Application layer not found - cannot analyze service coverage");
|
|
5242
|
+
return;
|
|
5243
|
+
}
|
|
5244
|
+
const serviceFiles = await findFiles("**/I*Service.cs", { cwd: structure.application });
|
|
5245
|
+
const serviceNames = serviceFiles.map((f) => {
|
|
5246
|
+
const basename = path11.basename(f, ".cs");
|
|
5247
|
+
return basename.startsWith("I") ? basename.slice(1) : basename;
|
|
5248
|
+
}).filter((n) => n.endsWith("Service")).map((n) => n.replace(/Service$/, ""));
|
|
5249
|
+
const testPath = path11.join(structure.root, "Tests", "Unit", "Services");
|
|
5250
|
+
let testFiles = [];
|
|
5251
|
+
try {
|
|
5252
|
+
testFiles = await findFiles("**/*ServiceTests.cs", { cwd: testPath });
|
|
5253
|
+
} catch {
|
|
5254
|
+
}
|
|
5255
|
+
const testedServices = testFiles.map((f) => {
|
|
5256
|
+
const basename = path11.basename(f, ".cs");
|
|
5257
|
+
return basename.replace(/ServiceTests$/, "");
|
|
5258
|
+
});
|
|
5259
|
+
result.services.total = serviceNames.length;
|
|
5260
|
+
result.services.items = serviceNames;
|
|
5261
|
+
result.services.tested = serviceNames.filter((s) => testedServices.includes(s)).length;
|
|
5262
|
+
result.services.coverage = result.services.total > 0 ? Math.round(result.services.tested / result.services.total * 100) : 0;
|
|
5263
|
+
result.services.missingTests = serviceNames.filter((s) => !testedServices.includes(s));
|
|
5264
|
+
for (const name of result.services.missingTests) {
|
|
5265
|
+
result.missing.push({
|
|
5266
|
+
name,
|
|
5267
|
+
type: "service",
|
|
5268
|
+
priority: determinePriority(name, "service"),
|
|
5269
|
+
recommendation: `Create unit tests for ${name}Service (CRUD operations, business logic, error handling)`,
|
|
5270
|
+
suggestedTestFile: `Tests/Unit/Services/${name}ServiceTests.cs`
|
|
5271
|
+
});
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
5274
|
+
async function analyzeControllerCoverage(structure, result) {
|
|
5275
|
+
if (!structure.api) {
|
|
5276
|
+
result.recommendations.push("API layer not found - cannot analyze controller coverage");
|
|
5277
|
+
return;
|
|
5278
|
+
}
|
|
5279
|
+
const controllerFiles = await findFiles("**/*Controller.cs", { cwd: structure.api });
|
|
5280
|
+
const controllerNames = controllerFiles.map((f) => {
|
|
5281
|
+
const basename = path11.basename(f, ".cs");
|
|
5282
|
+
return basename.replace(/Controller$/, "");
|
|
5283
|
+
}).filter((n) => n !== "Base");
|
|
5284
|
+
const testPath = path11.join(structure.root, "Tests", "Integration", "Controllers");
|
|
5285
|
+
let testFiles = [];
|
|
5286
|
+
try {
|
|
5287
|
+
testFiles = await findFiles("**/*ControllerTests.cs", { cwd: testPath });
|
|
5288
|
+
} catch {
|
|
5289
|
+
}
|
|
5290
|
+
const testedControllers = testFiles.map((f) => {
|
|
5291
|
+
const basename = path11.basename(f, ".cs");
|
|
5292
|
+
return basename.replace(/ControllerTests$/, "");
|
|
5293
|
+
});
|
|
5294
|
+
result.controllers.total = controllerNames.length;
|
|
5295
|
+
result.controllers.items = controllerNames;
|
|
5296
|
+
result.controllers.tested = controllerNames.filter((c) => testedControllers.includes(c)).length;
|
|
5297
|
+
result.controllers.coverage = result.controllers.total > 0 ? Math.round(result.controllers.tested / result.controllers.total * 100) : 0;
|
|
5298
|
+
result.controllers.missingTests = controllerNames.filter((c) => !testedControllers.includes(c));
|
|
5299
|
+
for (const name of result.controllers.missingTests) {
|
|
5300
|
+
result.missing.push({
|
|
5301
|
+
name,
|
|
5302
|
+
type: "controller",
|
|
5303
|
+
priority: determinePriority(name, "controller"),
|
|
5304
|
+
recommendation: `Create integration tests for ${name}Controller (HTTP status codes, authorization, validation)`,
|
|
5305
|
+
suggestedTestFile: `Tests/Integration/Controllers/${name}ControllerTests.cs`
|
|
5306
|
+
});
|
|
5307
|
+
}
|
|
5308
|
+
}
|
|
5309
|
+
function extractComponentNames(files, excludeSuffixes = []) {
|
|
5310
|
+
return files.map((f) => path11.basename(f, ".cs")).filter((name) => {
|
|
5311
|
+
const excludePatterns = [
|
|
5312
|
+
"Configuration",
|
|
5313
|
+
"Extensions",
|
|
5314
|
+
"Handler",
|
|
5315
|
+
"Specification",
|
|
5316
|
+
"Repository",
|
|
5317
|
+
"Service",
|
|
5318
|
+
"Controller",
|
|
5319
|
+
"Dto",
|
|
5320
|
+
"Validator",
|
|
5321
|
+
...excludeSuffixes
|
|
5322
|
+
];
|
|
5323
|
+
return !excludePatterns.some((pattern) => name.endsWith(pattern));
|
|
5324
|
+
}).filter((name) => !name.startsWith("I")).filter((name) => !name.startsWith("Base"));
|
|
5325
|
+
}
|
|
5326
|
+
function extractTestedNames(testFiles) {
|
|
5327
|
+
return testFiles.map((f) => {
|
|
5328
|
+
const basename = path11.basename(f, ".cs");
|
|
5329
|
+
return basename.replace(/Tests$/, "");
|
|
5330
|
+
});
|
|
5331
|
+
}
|
|
5332
|
+
function determinePriority(name, type) {
|
|
5333
|
+
const criticalPatterns = ["User", "Auth", "Payment", "Order", "Tenant", "Security", "Permission", "Role"];
|
|
5334
|
+
const highPatterns = ["Account", "Invoice", "Transaction", "Session", "Token", "Subscription"];
|
|
5335
|
+
const nameLower = name.toLowerCase();
|
|
5336
|
+
if (criticalPatterns.some((p) => nameLower.includes(p.toLowerCase()))) {
|
|
5337
|
+
return "critical";
|
|
5338
|
+
}
|
|
5339
|
+
if (highPatterns.some((p) => nameLower.includes(p.toLowerCase()))) {
|
|
5340
|
+
return "high";
|
|
5341
|
+
}
|
|
5342
|
+
if (type === "controller") {
|
|
5343
|
+
return "high";
|
|
5344
|
+
}
|
|
5345
|
+
if (type === "service") {
|
|
5346
|
+
return "medium";
|
|
5347
|
+
}
|
|
5348
|
+
return "medium";
|
|
5349
|
+
}
|
|
5350
|
+
function generateRecommendations(result) {
|
|
5351
|
+
if (result.summary.coverage < 50) {
|
|
5352
|
+
result.recommendations.push("CRITICAL: Overall test coverage is below 50%. Prioritize adding tests for critical components.");
|
|
5353
|
+
} else if (result.summary.coverage < 80) {
|
|
5354
|
+
result.recommendations.push("Test coverage is below recommended 80% threshold. Consider adding more tests.");
|
|
5355
|
+
}
|
|
5356
|
+
if (result.entities.coverage < 60) {
|
|
5357
|
+
result.recommendations.push(`Entity coverage (${result.entities.coverage}%) is low. Focus on testing factory methods, soft delete, and audit trails.`);
|
|
5358
|
+
}
|
|
5359
|
+
if (result.services.coverage < 70) {
|
|
5360
|
+
result.recommendations.push(`Service coverage (${result.services.coverage}%) is low. Ensure CRUD operations and business logic are tested.`);
|
|
5361
|
+
}
|
|
5362
|
+
if (result.controllers.coverage < 80) {
|
|
5363
|
+
result.recommendations.push(`Controller coverage (${result.controllers.coverage}%) is low. Add integration tests for HTTP endpoints.`);
|
|
5364
|
+
}
|
|
5365
|
+
const criticalMissing = result.missing.filter((m) => m.priority === "critical");
|
|
5366
|
+
if (criticalMissing.length > 0) {
|
|
5367
|
+
result.recommendations.push(`${criticalMissing.length} CRITICAL component(s) have no tests: ${criticalMissing.map((m) => m.name).join(", ")}`);
|
|
5368
|
+
}
|
|
5369
|
+
const securityComponents = result.missing.filter(
|
|
5370
|
+
(m) => m.name.toLowerCase().includes("auth") || m.name.toLowerCase().includes("security") || m.name.toLowerCase().includes("permission")
|
|
5371
|
+
);
|
|
5372
|
+
if (securityComponents.length > 0) {
|
|
5373
|
+
result.recommendations.push(`Security-sensitive components without tests: ${securityComponents.map((m) => m.name).join(", ")}. Add security tests immediately.`);
|
|
5374
|
+
}
|
|
5375
|
+
}
|
|
5376
|
+
function formatCoverageResult(result, format) {
|
|
5377
|
+
if (format === "json") {
|
|
5378
|
+
return JSON.stringify(result, null, 2);
|
|
5379
|
+
}
|
|
5380
|
+
const lines = [];
|
|
5381
|
+
lines.push("# Test Coverage Analysis");
|
|
5382
|
+
lines.push("");
|
|
5383
|
+
const coverageEmoji = result.summary.coverage >= 80 ? "\u2705" : result.summary.coverage >= 50 ? "\u26A0\uFE0F" : "\u274C";
|
|
5384
|
+
lines.push("## Summary");
|
|
5385
|
+
lines.push("");
|
|
5386
|
+
lines.push(`| Metric | Value |`);
|
|
5387
|
+
lines.push(`|--------|-------|`);
|
|
5388
|
+
lines.push(`| **Overall Coverage** | ${coverageEmoji} ${result.summary.coverage}% (${result.summary.tested}/${result.summary.total}) |`);
|
|
5389
|
+
lines.push(`| Entities | ${formatCoverageCell(result.entities)} |`);
|
|
5390
|
+
lines.push(`| Services | ${formatCoverageCell(result.services)} |`);
|
|
5391
|
+
lines.push(`| Controllers | ${formatCoverageCell(result.controllers)} |`);
|
|
5392
|
+
lines.push("");
|
|
5393
|
+
if (format === "detailed") {
|
|
5394
|
+
if (result.entities.missingTests.length > 0) {
|
|
5395
|
+
lines.push("## Missing Entity Tests");
|
|
5396
|
+
lines.push("");
|
|
5397
|
+
for (const name of result.entities.missingTests) {
|
|
5398
|
+
lines.push(`- [ ] ${name}`);
|
|
5399
|
+
}
|
|
5400
|
+
lines.push("");
|
|
5401
|
+
}
|
|
5402
|
+
if (result.services.missingTests.length > 0) {
|
|
5403
|
+
lines.push("## Missing Service Tests");
|
|
5404
|
+
lines.push("");
|
|
5405
|
+
for (const name of result.services.missingTests) {
|
|
5406
|
+
lines.push(`- [ ] ${name}Service`);
|
|
5407
|
+
}
|
|
5408
|
+
lines.push("");
|
|
5409
|
+
}
|
|
5410
|
+
if (result.controllers.missingTests.length > 0) {
|
|
5411
|
+
lines.push("## Missing Controller Tests");
|
|
5412
|
+
lines.push("");
|
|
5413
|
+
for (const name of result.controllers.missingTests) {
|
|
5414
|
+
lines.push(`- [ ] ${name}Controller`);
|
|
5415
|
+
}
|
|
5416
|
+
lines.push("");
|
|
5417
|
+
}
|
|
5418
|
+
}
|
|
5419
|
+
if (result.missing.length > 0) {
|
|
5420
|
+
lines.push("## Missing Tests (By Priority)");
|
|
5421
|
+
lines.push("");
|
|
5422
|
+
const priorities = ["critical", "high", "medium", "low"];
|
|
5423
|
+
for (const priority of priorities) {
|
|
5424
|
+
const items = result.missing.filter((m) => m.priority === priority);
|
|
5425
|
+
if (items.length > 0) {
|
|
5426
|
+
const emoji = priority === "critical" ? "\u{1F534}" : priority === "high" ? "\u{1F7E0}" : priority === "medium" ? "\u{1F7E1}" : "\u{1F7E2}";
|
|
5427
|
+
lines.push(`### ${emoji} ${priority.charAt(0).toUpperCase() + priority.slice(1)} Priority`);
|
|
5428
|
+
lines.push("");
|
|
5429
|
+
lines.push("| Component | Type | Suggested Test File |");
|
|
5430
|
+
lines.push("|-----------|------|---------------------|");
|
|
5431
|
+
for (const item of items) {
|
|
5432
|
+
lines.push(`| ${item.name} | ${item.type} | \`${item.suggestedTestFile}\` |`);
|
|
5433
|
+
}
|
|
5434
|
+
lines.push("");
|
|
5435
|
+
}
|
|
5436
|
+
}
|
|
5437
|
+
}
|
|
5438
|
+
if (result.recommendations.length > 0) {
|
|
5439
|
+
lines.push("## Recommendations");
|
|
5440
|
+
lines.push("");
|
|
5441
|
+
for (const rec of result.recommendations) {
|
|
5442
|
+
const emoji = rec.includes("CRITICAL") ? "\u{1F534}" : rec.includes("low") ? "\u26A0\uFE0F" : "\u{1F4A1}";
|
|
5443
|
+
lines.push(`${emoji} ${rec}`);
|
|
5444
|
+
lines.push("");
|
|
5445
|
+
}
|
|
5446
|
+
}
|
|
5447
|
+
lines.push("## Quick Actions");
|
|
5448
|
+
lines.push("");
|
|
5449
|
+
lines.push("Generate tests for missing components using:");
|
|
5450
|
+
lines.push("");
|
|
5451
|
+
if (result.missing.length > 0) {
|
|
5452
|
+
const firstMissing = result.missing[0];
|
|
5453
|
+
lines.push("```");
|
|
5454
|
+
lines.push(`scaffold_tests(target: "${firstMissing.type}", name: "${firstMissing.name}")`);
|
|
5455
|
+
lines.push("```");
|
|
5456
|
+
}
|
|
5457
|
+
lines.push("");
|
|
5458
|
+
return lines.join("\n");
|
|
5459
|
+
}
|
|
5460
|
+
function formatCoverageCell(category) {
|
|
5461
|
+
const emoji = category.coverage >= 80 ? "\u2705" : category.coverage >= 50 ? "\u26A0\uFE0F" : "\u274C";
|
|
5462
|
+
return `${emoji} ${category.coverage}% (${category.tested}/${category.total})`;
|
|
5463
|
+
}
|
|
5464
|
+
|
|
5465
|
+
// src/tools/validate-test-conventions.ts
|
|
5466
|
+
import path12 from "path";
|
|
5467
|
+
var validateTestConventionsTool = {
|
|
5468
|
+
name: "validate_test_conventions",
|
|
5469
|
+
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).",
|
|
5470
|
+
inputSchema: {
|
|
5471
|
+
type: "object",
|
|
5472
|
+
properties: {
|
|
5473
|
+
path: {
|
|
5474
|
+
type: "string",
|
|
5475
|
+
description: "Project path to validate (default: configured SmartStack path)"
|
|
5476
|
+
},
|
|
5477
|
+
checks: {
|
|
5478
|
+
type: "array",
|
|
5479
|
+
items: {
|
|
5480
|
+
type: "string",
|
|
5481
|
+
enum: ["naming", "structure", "patterns", "assertions", "mocking", "all"]
|
|
5482
|
+
},
|
|
5483
|
+
default: ["all"],
|
|
5484
|
+
description: "Types of convention checks to perform"
|
|
5485
|
+
},
|
|
5486
|
+
autoFix: {
|
|
5487
|
+
type: "boolean",
|
|
5488
|
+
default: false,
|
|
5489
|
+
description: "Automatically fix minor issues (naming only)"
|
|
5490
|
+
}
|
|
5491
|
+
}
|
|
5492
|
+
}
|
|
5493
|
+
};
|
|
5494
|
+
var PATTERNS = {
|
|
5495
|
+
// Test method naming: MethodName_WhenCondition_ShouldExpectedResult
|
|
5496
|
+
testMethodNaming: /^(\w+)_When(\w+)_Should(\w+)$/,
|
|
5497
|
+
// Alternative valid patterns
|
|
5498
|
+
testMethodNamingAlt: /^(\w+)_Should(\w+)_When(\w+)$/,
|
|
5499
|
+
// Fact/Theory attributes
|
|
5500
|
+
factAttribute: /\[Fact\]/,
|
|
5501
|
+
theoryAttribute: /\[Theory\]/,
|
|
5502
|
+
// AAA pattern comments
|
|
5503
|
+
arrangeComment: /\/\/\s*Arrange/i,
|
|
5504
|
+
actComment: /\/\/\s*Act/i,
|
|
5505
|
+
assertComment: /\/\/\s*Assert/i,
|
|
5506
|
+
// FluentAssertions
|
|
5507
|
+
fluentAssertions: /\.Should\(\)/,
|
|
5508
|
+
// Moq
|
|
5509
|
+
moqUsage: /new Mock<|Mock<\w+>/,
|
|
5510
|
+
moqSetup: /\.Setup\(|\.Verify\(/,
|
|
5511
|
+
// Test class naming
|
|
5512
|
+
testClassNaming: /public class (\w+)Tests/,
|
|
5513
|
+
// xUnit patterns
|
|
5514
|
+
asyncTestMethod: /public async Task (\w+)/,
|
|
5515
|
+
syncTestMethod: /public void (\w+)/
|
|
5516
|
+
};
|
|
5517
|
+
async function handleValidateTestConventions(args, config) {
|
|
5518
|
+
const input = ValidateTestConventionsInputSchema.parse(args);
|
|
5519
|
+
const projectPath = input.path || config.smartstack.projectPath;
|
|
5520
|
+
const checks = input.checks.includes("all") ? ["naming", "structure", "patterns", "assertions", "mocking"] : input.checks;
|
|
5521
|
+
logger.info("Validating test conventions", { projectPath, checks });
|
|
5522
|
+
const structure = await findSmartStackStructure(projectPath);
|
|
5523
|
+
const result = {
|
|
5524
|
+
valid: true,
|
|
5525
|
+
violations: [],
|
|
5526
|
+
suggestions: [],
|
|
5527
|
+
autoFixedCount: 0
|
|
5528
|
+
};
|
|
5529
|
+
const testsPath = path12.join(structure.root, "Tests");
|
|
5530
|
+
try {
|
|
5531
|
+
let testFiles = [];
|
|
5532
|
+
try {
|
|
5533
|
+
testFiles = await findFiles("**/*Tests.cs", { cwd: testsPath });
|
|
5534
|
+
} catch {
|
|
5535
|
+
result.suggestions.push("No Tests directory found. Create a Tests project with Unit and Integration subdirectories.");
|
|
5536
|
+
return formatValidationResult(result);
|
|
5537
|
+
}
|
|
5538
|
+
if (testFiles.length === 0) {
|
|
5539
|
+
result.suggestions.push("No test files found. Create test files following the pattern {ComponentName}Tests.cs");
|
|
5540
|
+
return formatValidationResult(result);
|
|
5541
|
+
}
|
|
5542
|
+
if (checks.includes("structure")) {
|
|
5543
|
+
await validateStructure(testsPath, testFiles, result);
|
|
5544
|
+
}
|
|
5545
|
+
for (const testFile of testFiles) {
|
|
5546
|
+
const fullPath = path12.join(testsPath, testFile);
|
|
5547
|
+
const content = await readText(fullPath);
|
|
5548
|
+
if (checks.includes("naming")) {
|
|
5549
|
+
validateNaming(testFile, content, result, input.autoFix);
|
|
5550
|
+
}
|
|
5551
|
+
if (checks.includes("patterns")) {
|
|
5552
|
+
validatePatterns(testFile, content, result);
|
|
5553
|
+
}
|
|
5554
|
+
if (checks.includes("assertions")) {
|
|
5555
|
+
validateAssertions(testFile, content, result);
|
|
5556
|
+
}
|
|
5557
|
+
if (checks.includes("mocking")) {
|
|
5558
|
+
validateMocking(testFile, content, result);
|
|
5559
|
+
}
|
|
5560
|
+
}
|
|
5561
|
+
result.valid = result.violations.filter((v) => v.severity === "error").length === 0;
|
|
5562
|
+
} catch (error) {
|
|
5563
|
+
logger.error("Error validating test conventions", error);
|
|
5564
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
5565
|
+
}
|
|
5566
|
+
return formatValidationResult(result);
|
|
5567
|
+
}
|
|
5568
|
+
async function validateStructure(testsPath, testFiles, result) {
|
|
5569
|
+
const expectedDirs = ["Unit", "Integration"];
|
|
5570
|
+
const foundDirs = /* @__PURE__ */ new Set();
|
|
5571
|
+
for (const file of testFiles) {
|
|
5572
|
+
const parts = file.split(path12.sep);
|
|
5573
|
+
if (parts.length > 1) {
|
|
5574
|
+
foundDirs.add(parts[0]);
|
|
5575
|
+
}
|
|
5576
|
+
}
|
|
5577
|
+
for (const dir of expectedDirs) {
|
|
5578
|
+
if (!foundDirs.has(dir)) {
|
|
5579
|
+
result.violations.push({
|
|
5580
|
+
type: "structure",
|
|
5581
|
+
severity: "warning",
|
|
5582
|
+
file: testsPath,
|
|
5583
|
+
message: `Missing expected directory: Tests/${dir}`,
|
|
5584
|
+
suggestion: `Create Tests/${dir} directory for ${dir.toLowerCase()} tests`,
|
|
5585
|
+
autoFixable: false
|
|
5586
|
+
});
|
|
5587
|
+
}
|
|
5588
|
+
}
|
|
5589
|
+
const hasUnitDomain = testFiles.some((f) => f.includes(path12.join("Unit", "Domain")));
|
|
5590
|
+
const hasIntegrationControllers = testFiles.some((f) => f.includes(path12.join("Integration", "Controllers")));
|
|
5591
|
+
if (!hasUnitDomain && testFiles.some((f) => f.includes("Unit"))) {
|
|
5592
|
+
result.suggestions.push("Consider organizing unit tests into subdirectories: Unit/Domain, Unit/Services, Unit/Validators");
|
|
5593
|
+
}
|
|
5594
|
+
if (!hasIntegrationControllers && testFiles.some((f) => f.includes("Integration"))) {
|
|
5595
|
+
result.suggestions.push("Consider organizing integration tests into subdirectories: Integration/Controllers, Integration/Repositories");
|
|
5596
|
+
}
|
|
5597
|
+
const hasSecurityTests = testFiles.some((f) => f.includes("Security"));
|
|
5598
|
+
if (!hasSecurityTests) {
|
|
5599
|
+
result.suggestions.push("Consider adding a Tests/Security directory for security-focused tests");
|
|
5600
|
+
}
|
|
5601
|
+
}
|
|
5602
|
+
function validateNaming(testFile, content, result, _autoFix) {
|
|
5603
|
+
const classMatch = content.match(/public class (\w+)/);
|
|
5604
|
+
if (classMatch) {
|
|
5605
|
+
const className = classMatch[1];
|
|
5606
|
+
if (!className.endsWith("Tests")) {
|
|
5607
|
+
result.violations.push({
|
|
5608
|
+
type: "naming",
|
|
5609
|
+
severity: "error",
|
|
5610
|
+
file: testFile,
|
|
5611
|
+
message: `Test class '${className}' should end with 'Tests'`,
|
|
5612
|
+
suggestion: `Rename to '${className}Tests'`,
|
|
5613
|
+
autoFixable: true
|
|
5614
|
+
});
|
|
5615
|
+
}
|
|
5616
|
+
}
|
|
5617
|
+
const methodMatches = content.matchAll(/\[(Fact|Theory)\]\s*\n\s*public (?:async Task|void) (\w+)\(/g);
|
|
5618
|
+
for (const match of methodMatches) {
|
|
5619
|
+
const methodName = match[2];
|
|
5620
|
+
const isValidName = PATTERNS.testMethodNaming.test(methodName) || PATTERNS.testMethodNamingAlt.test(methodName);
|
|
5621
|
+
if (!isValidName) {
|
|
5622
|
+
if (methodName.includes("Test") && !methodName.includes("_")) {
|
|
5623
|
+
result.violations.push({
|
|
5624
|
+
type: "naming",
|
|
5625
|
+
severity: "warning",
|
|
5626
|
+
file: testFile,
|
|
5627
|
+
message: `Test method '${methodName}' doesn't follow naming convention`,
|
|
5628
|
+
suggestion: `Rename to format: {Method}_When{Condition}_Should{Result} (e.g., GetById_WhenExists_ShouldReturnEntity)`,
|
|
5629
|
+
autoFixable: false
|
|
5630
|
+
});
|
|
5631
|
+
} else if (!methodName.includes("_")) {
|
|
5632
|
+
result.violations.push({
|
|
5633
|
+
type: "naming",
|
|
5634
|
+
severity: "warning",
|
|
5635
|
+
file: testFile,
|
|
5636
|
+
message: `Test method '${methodName}' should use underscores to separate parts`,
|
|
5637
|
+
suggestion: `Use format: {Method}_When{Condition}_Should{Result}`,
|
|
5638
|
+
autoFixable: false
|
|
5639
|
+
});
|
|
5640
|
+
}
|
|
5641
|
+
}
|
|
5642
|
+
}
|
|
5643
|
+
}
|
|
5644
|
+
function validatePatterns(testFile, content, result) {
|
|
5645
|
+
const methodBlocks = content.matchAll(/\[(Fact|Theory)\][^\[]*?public (?:async Task|void) \w+\([^)]*\)\s*\{([^}]+)\}/gs);
|
|
5646
|
+
for (const match of methodBlocks) {
|
|
5647
|
+
const methodBody = match[2];
|
|
5648
|
+
const hasArrange = PATTERNS.arrangeComment.test(methodBody);
|
|
5649
|
+
const hasAct = PATTERNS.actComment.test(methodBody);
|
|
5650
|
+
const hasAssert = PATTERNS.assertComment.test(methodBody);
|
|
5651
|
+
if (!hasArrange && !hasAct && !hasAssert && methodBody.split("\n").length > 3) {
|
|
5652
|
+
result.violations.push({
|
|
5653
|
+
type: "pattern",
|
|
5654
|
+
severity: "warning",
|
|
5655
|
+
file: testFile,
|
|
5656
|
+
message: "Test method missing AAA (Arrange-Act-Assert) comments",
|
|
5657
|
+
suggestion: "Add // Arrange, // Act, // Assert comments to improve readability",
|
|
5658
|
+
autoFixable: false
|
|
5659
|
+
});
|
|
5660
|
+
break;
|
|
5661
|
+
}
|
|
5662
|
+
}
|
|
5663
|
+
const asyncMethods = content.matchAll(/public async Task (\w+)\([^)]*\)\s*\{([^}]+)\}/gs);
|
|
5664
|
+
for (const match of asyncMethods) {
|
|
5665
|
+
const methodName = match[1];
|
|
5666
|
+
const methodBody = match[2];
|
|
5667
|
+
if (!methodBody.includes("await")) {
|
|
5668
|
+
result.violations.push({
|
|
5669
|
+
type: "pattern",
|
|
5670
|
+
severity: "warning",
|
|
5671
|
+
file: testFile,
|
|
5672
|
+
message: `Async test method '${methodName}' doesn't contain 'await'`,
|
|
5673
|
+
suggestion: "Either add await or change to synchronous void method",
|
|
5674
|
+
autoFixable: false
|
|
5675
|
+
});
|
|
5676
|
+
}
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
function validateAssertions(testFile, content, result) {
|
|
5680
|
+
const hasFluentAssertions = PATTERNS.fluentAssertions.test(content);
|
|
5681
|
+
const hasXunitAssert = /Assert\.\w+\(/.test(content);
|
|
5682
|
+
if (hasXunitAssert && !hasFluentAssertions) {
|
|
5683
|
+
result.violations.push({
|
|
5684
|
+
type: "assertion",
|
|
5685
|
+
severity: "warning",
|
|
5686
|
+
file: testFile,
|
|
5687
|
+
message: "Using xUnit Assert instead of FluentAssertions",
|
|
5688
|
+
suggestion: "Replace Assert.Equal(expected, actual) with actual.Should().Be(expected)",
|
|
5689
|
+
autoFixable: false
|
|
5690
|
+
});
|
|
5691
|
+
}
|
|
5692
|
+
if (hasFluentAssertions) {
|
|
5693
|
+
if (content.includes(".Should().NotBe(null)")) {
|
|
5694
|
+
result.violations.push({
|
|
5695
|
+
type: "assertion",
|
|
5696
|
+
severity: "warning",
|
|
5697
|
+
file: testFile,
|
|
5698
|
+
message: "Use .Should().NotBeNull() instead of .Should().NotBe(null)",
|
|
5699
|
+
suggestion: "Replace .Should().NotBe(null) with .Should().NotBeNull()",
|
|
5700
|
+
autoFixable: true
|
|
5701
|
+
});
|
|
5702
|
+
}
|
|
5703
|
+
if (content.includes(".Should().Throw<") && content.includes("async")) {
|
|
5704
|
+
if (!content.includes(".Should().ThrowAsync<")) {
|
|
5705
|
+
result.suggestions.push("Use .Should().ThrowAsync<T>() for async exception testing");
|
|
5706
|
+
}
|
|
5707
|
+
}
|
|
5708
|
+
}
|
|
5709
|
+
const methodBlocks = content.matchAll(/\[(Fact|Theory)\][^\[]*?public (?:async Task|void) (\w+)\([^)]*\)\s*\{([^}]+)\}/gs);
|
|
5710
|
+
for (const match of methodBlocks) {
|
|
5711
|
+
const methodName = match[2];
|
|
5712
|
+
const methodBody = match[3];
|
|
5713
|
+
const hasAssertion = methodBody.includes(".Should()") || methodBody.includes("Assert.") || methodBody.includes("Verify(") || methodBody.includes("VerifyAll()");
|
|
5714
|
+
if (!hasAssertion) {
|
|
5715
|
+
result.violations.push({
|
|
5716
|
+
type: "assertion",
|
|
5717
|
+
severity: "error",
|
|
5718
|
+
file: testFile,
|
|
5719
|
+
message: `Test method '${methodName}' has no assertions`,
|
|
5720
|
+
suggestion: "Add at least one assertion to verify expected behavior",
|
|
5721
|
+
autoFixable: false
|
|
5722
|
+
});
|
|
5723
|
+
}
|
|
5724
|
+
}
|
|
5725
|
+
}
|
|
5726
|
+
function validateMocking(testFile, content, result) {
|
|
5727
|
+
const hasMoq = PATTERNS.moqUsage.test(content);
|
|
5728
|
+
if (hasMoq) {
|
|
5729
|
+
if (content.includes("new Mock<") && !content.includes(".Setup(")) {
|
|
5730
|
+
result.violations.push({
|
|
5731
|
+
type: "mocking",
|
|
5732
|
+
severity: "warning",
|
|
5733
|
+
file: testFile,
|
|
5734
|
+
message: "Mock objects created but no Setup() calls found",
|
|
5735
|
+
suggestion: "Add .Setup() calls to define mock behavior",
|
|
5736
|
+
autoFixable: false
|
|
5737
|
+
});
|
|
5738
|
+
}
|
|
5739
|
+
const hasSetup = content.includes(".Setup(");
|
|
5740
|
+
const hasVerify = content.includes(".Verify(") || content.includes(".VerifyAll()");
|
|
5741
|
+
if (hasSetup && !hasVerify) {
|
|
5742
|
+
result.suggestions.push(`Consider adding .Verify() calls in ${testFile} to verify mock interactions`);
|
|
5743
|
+
}
|
|
5744
|
+
if (content.includes("MockBehavior.Strict")) {
|
|
5745
|
+
result.suggestions.push("Using MockBehavior.Strict - ensure all mock calls are set up");
|
|
5746
|
+
}
|
|
5747
|
+
}
|
|
5748
|
+
if (testFile.includes("Controller")) {
|
|
5749
|
+
if (!hasMoq && !content.includes("WebApplicationFactory")) {
|
|
5750
|
+
result.violations.push({
|
|
5751
|
+
type: "mocking",
|
|
5752
|
+
severity: "warning",
|
|
5753
|
+
file: testFile,
|
|
5754
|
+
message: "Controller tests should use mocking or WebApplicationFactory",
|
|
5755
|
+
suggestion: "Use Moq for unit tests or WebApplicationFactory for integration tests",
|
|
5756
|
+
autoFixable: false
|
|
5757
|
+
});
|
|
5758
|
+
}
|
|
5759
|
+
}
|
|
5760
|
+
}
|
|
5761
|
+
function formatValidationResult(result) {
|
|
5762
|
+
const lines = [];
|
|
5763
|
+
lines.push("# Test Convention Validation");
|
|
5764
|
+
lines.push("");
|
|
5765
|
+
const status = result.valid ? "\u2705 PASSED" : "\u274C FAILED";
|
|
5766
|
+
const errorCount = result.violations.filter((v) => v.severity === "error").length;
|
|
5767
|
+
const warningCount = result.violations.filter((v) => v.severity === "warning").length;
|
|
5768
|
+
lines.push("## Summary");
|
|
5769
|
+
lines.push("");
|
|
5770
|
+
lines.push(`| Status | ${status} |`);
|
|
5771
|
+
lines.push("|--------|----------|");
|
|
5772
|
+
lines.push(`| Errors | ${errorCount} |`);
|
|
5773
|
+
lines.push(`| Warnings | ${warningCount} |`);
|
|
5774
|
+
if (result.autoFixedCount > 0) {
|
|
5775
|
+
lines.push(`| Auto-fixed | ${result.autoFixedCount} |`);
|
|
5776
|
+
}
|
|
5777
|
+
lines.push("");
|
|
5778
|
+
if (result.violations.length > 0) {
|
|
5779
|
+
lines.push("## Violations");
|
|
5780
|
+
lines.push("");
|
|
5781
|
+
const byType = /* @__PURE__ */ new Map();
|
|
5782
|
+
for (const v of result.violations) {
|
|
5783
|
+
const existing = byType.get(v.type) || [];
|
|
5784
|
+
existing.push(v);
|
|
5785
|
+
byType.set(v.type, existing);
|
|
5786
|
+
}
|
|
5787
|
+
for (const [type, violations] of byType) {
|
|
5788
|
+
lines.push(`### ${type.charAt(0).toUpperCase() + type.slice(1)} Violations`);
|
|
5789
|
+
lines.push("");
|
|
5790
|
+
for (const v of violations) {
|
|
5791
|
+
const emoji = v.severity === "error" ? "\u274C" : "\u26A0\uFE0F";
|
|
5792
|
+
lines.push(`${emoji} **${v.file}**`);
|
|
5793
|
+
lines.push(` - ${v.message}`);
|
|
5794
|
+
lines.push(` - \u{1F4A1} ${v.suggestion}`);
|
|
5795
|
+
if (v.autoFixable) {
|
|
5796
|
+
lines.push(` - \u{1F527} Auto-fixable`);
|
|
5797
|
+
}
|
|
5798
|
+
lines.push("");
|
|
5799
|
+
}
|
|
5800
|
+
}
|
|
5801
|
+
}
|
|
5802
|
+
if (result.suggestions.length > 0) {
|
|
5803
|
+
lines.push("## Suggestions");
|
|
5804
|
+
lines.push("");
|
|
5805
|
+
for (const suggestion of result.suggestions) {
|
|
5806
|
+
lines.push(`\u{1F4A1} ${suggestion}`);
|
|
5807
|
+
lines.push("");
|
|
5808
|
+
}
|
|
5809
|
+
}
|
|
5810
|
+
lines.push("## SmartStack Test Conventions");
|
|
5811
|
+
lines.push("");
|
|
5812
|
+
lines.push("### Naming");
|
|
5813
|
+
lines.push("- Files: `{ComponentName}Tests.cs`");
|
|
5814
|
+
lines.push("- Classes: `{ComponentName}Tests`");
|
|
5815
|
+
lines.push("- Methods: `{MethodName}_When{Condition}_Should{ExpectedResult}`");
|
|
5816
|
+
lines.push("");
|
|
5817
|
+
lines.push("### Structure");
|
|
5818
|
+
lines.push("```");
|
|
5819
|
+
lines.push("Tests/");
|
|
5820
|
+
lines.push("\u251C\u2500\u2500 Unit/");
|
|
5821
|
+
lines.push("\u2502 \u251C\u2500\u2500 Domain/");
|
|
5822
|
+
lines.push("\u2502 \u251C\u2500\u2500 Services/");
|
|
5823
|
+
lines.push("\u2502 \u2514\u2500\u2500 Validators/");
|
|
5824
|
+
lines.push("\u251C\u2500\u2500 Integration/");
|
|
5825
|
+
lines.push("\u2502 \u251C\u2500\u2500 Controllers/");
|
|
5826
|
+
lines.push("\u2502 \u2514\u2500\u2500 Repositories/");
|
|
5827
|
+
lines.push("\u2514\u2500\u2500 Security/");
|
|
5828
|
+
lines.push("```");
|
|
5829
|
+
lines.push("");
|
|
5830
|
+
lines.push("### Pattern (AAA)");
|
|
5831
|
+
lines.push("```csharp");
|
|
5832
|
+
lines.push("[Fact]");
|
|
5833
|
+
lines.push("public async Task GetById_WhenExists_ShouldReturnEntity()");
|
|
5834
|
+
lines.push("{");
|
|
5835
|
+
lines.push(" // Arrange");
|
|
5836
|
+
lines.push(" var id = Guid.NewGuid();");
|
|
5837
|
+
lines.push("");
|
|
5838
|
+
lines.push(" // Act");
|
|
5839
|
+
lines.push(" var result = await _sut.GetByIdAsync(id);");
|
|
5840
|
+
lines.push("");
|
|
5841
|
+
lines.push(" // Assert");
|
|
5842
|
+
lines.push(" result.Should().NotBeNull();");
|
|
5843
|
+
lines.push("}");
|
|
5844
|
+
lines.push("```");
|
|
5845
|
+
lines.push("");
|
|
5846
|
+
return lines.join("\n");
|
|
5847
|
+
}
|
|
5848
|
+
|
|
5849
|
+
// src/tools/suggest-test-scenarios.ts
|
|
5850
|
+
import path13 from "path";
|
|
5851
|
+
var suggestTestScenariosTool = {
|
|
5852
|
+
name: "suggest_test_scenarios",
|
|
5853
|
+
description: "Analyze source code and suggest test scenarios based on detected methods, parameters, and patterns. Generates comprehensive test case recommendations for SmartStack components.",
|
|
5854
|
+
inputSchema: {
|
|
5855
|
+
type: "object",
|
|
5856
|
+
properties: {
|
|
5857
|
+
target: {
|
|
5858
|
+
type: "string",
|
|
5859
|
+
enum: ["entity", "service", "controller", "file"],
|
|
5860
|
+
description: "Type of target to analyze"
|
|
5861
|
+
},
|
|
5862
|
+
name: {
|
|
5863
|
+
type: "string",
|
|
5864
|
+
description: "Component name or file path"
|
|
5865
|
+
},
|
|
5866
|
+
depth: {
|
|
5867
|
+
type: "string",
|
|
5868
|
+
enum: ["basic", "comprehensive", "security-focused"],
|
|
5869
|
+
default: "comprehensive",
|
|
5870
|
+
description: "Depth of analysis"
|
|
5871
|
+
}
|
|
5872
|
+
},
|
|
5873
|
+
required: ["target", "name"]
|
|
5874
|
+
}
|
|
5875
|
+
};
|
|
5876
|
+
var PATTERNS2 = {
|
|
5877
|
+
// Method signatures
|
|
5878
|
+
publicMethod: /public\s+(?:async\s+)?(?:Task<)?(\w+)>?\s+(\w+)\s*\(([^)]*)\)/g,
|
|
5879
|
+
// Parameter extraction
|
|
5880
|
+
parameter: /(?:(\w+)\s+)?(\w+)\s+(\w+)(?:\s*=\s*([^,)]+))?/g,
|
|
5881
|
+
// Property
|
|
5882
|
+
property: /public\s+(?:virtual\s+)?(\w+)\??\s+(\w+)\s*\{\s*get;/g,
|
|
5883
|
+
// Validation attributes
|
|
5884
|
+
validationAttribute: /\[(Required|MaxLength|MinLength|Range|EmailAddress|Phone|Url|RegularExpression)\s*(?:\(([^)]*)\))?\]/g,
|
|
5885
|
+
// Entity patterns
|
|
5886
|
+
baseEntity: /:\s*(?:BaseEntity|SystemEntity)/,
|
|
5887
|
+
tenantEntity: /ITenantEntity/,
|
|
5888
|
+
// Factory method
|
|
5889
|
+
factoryMethod: /public\s+static\s+\w+\s+Create\s*\(/,
|
|
5890
|
+
// Soft delete
|
|
5891
|
+
softDelete: /public\s+void\s+SoftDelete/
|
|
5892
|
+
};
|
|
5893
|
+
async function handleSuggestTestScenarios(args, config) {
|
|
5894
|
+
const input = SuggestTestScenariosInputSchema.parse(args);
|
|
5895
|
+
logger.info("Suggesting test scenarios", { target: input.target, name: input.name, depth: input.depth });
|
|
5896
|
+
const structure = await findSmartStackStructure(config.smartstack.projectPath);
|
|
5897
|
+
const result = {
|
|
5898
|
+
target: input.name,
|
|
5899
|
+
targetType: input.target,
|
|
5900
|
+
detectedMethods: [],
|
|
5901
|
+
scenarios: [],
|
|
5902
|
+
coverage: {
|
|
5903
|
+
happyPath: 0,
|
|
5904
|
+
validation: 0,
|
|
5905
|
+
errorHandling: 0,
|
|
5906
|
+
security: 0,
|
|
5907
|
+
edgeCases: 0
|
|
5908
|
+
}
|
|
5909
|
+
};
|
|
5910
|
+
try {
|
|
5911
|
+
const sourceFile = await findSourceFile(input.target, input.name, structure, config);
|
|
5912
|
+
if (!sourceFile) {
|
|
5913
|
+
return `Error: Could not find ${input.target} '${input.name}'`;
|
|
5914
|
+
}
|
|
5915
|
+
const content = await readText(sourceFile);
|
|
5916
|
+
result.detectedMethods = parseSourceCode(content);
|
|
5917
|
+
result.scenarios = generateScenarios(
|
|
5918
|
+
result.detectedMethods,
|
|
5919
|
+
content,
|
|
5920
|
+
input.target,
|
|
5921
|
+
input.depth
|
|
5922
|
+
);
|
|
5923
|
+
calculateCoverageDistribution(result);
|
|
5924
|
+
} catch (error) {
|
|
5925
|
+
logger.error("Error suggesting test scenarios", error);
|
|
5926
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
5927
|
+
}
|
|
5928
|
+
return formatScenariosResult(result, input.depth);
|
|
5929
|
+
}
|
|
5930
|
+
async function findSourceFile(target, name, structure, _config) {
|
|
5931
|
+
let searchPath;
|
|
5932
|
+
let pattern;
|
|
5933
|
+
switch (target) {
|
|
5934
|
+
case "entity":
|
|
5935
|
+
searchPath = structure.domain || structure.root;
|
|
5936
|
+
pattern = `**/${name}.cs`;
|
|
5937
|
+
break;
|
|
5938
|
+
case "service":
|
|
5939
|
+
searchPath = structure.application || structure.root;
|
|
5940
|
+
pattern = `**/${name}Service.cs`;
|
|
5941
|
+
break;
|
|
5942
|
+
case "controller":
|
|
5943
|
+
searchPath = structure.api || structure.root;
|
|
5944
|
+
pattern = `**/${name}Controller.cs`;
|
|
5945
|
+
break;
|
|
5946
|
+
case "file":
|
|
5947
|
+
return path13.isAbsolute(name) ? name : path13.join(structure.root, name);
|
|
5948
|
+
default:
|
|
5949
|
+
return null;
|
|
5950
|
+
}
|
|
5951
|
+
const files = await findFiles(pattern, { cwd: searchPath });
|
|
5952
|
+
if (files.length === 0) {
|
|
5953
|
+
const altPattern = `**/*${name}*.cs`;
|
|
5954
|
+
const altFiles = await findFiles(altPattern, { cwd: searchPath });
|
|
5955
|
+
if (altFiles.length > 0) {
|
|
5956
|
+
return path13.join(searchPath, altFiles[0]);
|
|
5957
|
+
}
|
|
5958
|
+
return null;
|
|
5959
|
+
}
|
|
5960
|
+
return path13.join(searchPath, files[0]);
|
|
5961
|
+
}
|
|
5962
|
+
function parseSourceCode(content) {
|
|
5963
|
+
const methods = [];
|
|
5964
|
+
PATTERNS2.publicMethod.lastIndex = 0;
|
|
5965
|
+
let match;
|
|
5966
|
+
while ((match = PATTERNS2.publicMethod.exec(content)) !== null) {
|
|
5967
|
+
const returnType = match[1];
|
|
5968
|
+
const methodName = match[2];
|
|
5969
|
+
const parametersStr = match[3];
|
|
5970
|
+
const parameters = [];
|
|
5971
|
+
if (parametersStr.trim()) {
|
|
5972
|
+
const params = parametersStr.split(",");
|
|
5973
|
+
for (const param of params) {
|
|
5974
|
+
const trimmed = param.trim();
|
|
5975
|
+
if (trimmed) {
|
|
5976
|
+
const parts = trimmed.split(/\s+/);
|
|
5977
|
+
if (parts.length >= 2) {
|
|
5978
|
+
parameters.push(`${parts[parts.length - 2]} ${parts[parts.length - 1]}`);
|
|
5979
|
+
}
|
|
5980
|
+
}
|
|
5981
|
+
}
|
|
5982
|
+
}
|
|
5983
|
+
const isAsync = content.substring(Math.max(0, match.index - 20), match.index).includes("async");
|
|
5984
|
+
const isPublic = content.substring(Math.max(0, match.index - 20), match.index).includes("public");
|
|
5985
|
+
if (isPublic && !methodName.startsWith("get_") && !methodName.startsWith("set_")) {
|
|
5986
|
+
methods.push({
|
|
5987
|
+
name: methodName,
|
|
5988
|
+
returnType,
|
|
5989
|
+
parameters,
|
|
5990
|
+
isAsync,
|
|
5991
|
+
visibility: "public"
|
|
5992
|
+
});
|
|
5993
|
+
}
|
|
5994
|
+
}
|
|
5995
|
+
return methods;
|
|
5996
|
+
}
|
|
5997
|
+
function generateScenarios(methods, content, target, depth) {
|
|
5998
|
+
const scenarios = [];
|
|
5999
|
+
const isEntity = PATTERNS2.baseEntity.test(content);
|
|
6000
|
+
const hasTenant = PATTERNS2.tenantEntity.test(content);
|
|
6001
|
+
const hasFactoryMethod = PATTERNS2.factoryMethod.test(content);
|
|
6002
|
+
const hasSoftDelete = PATTERNS2.softDelete.test(content);
|
|
6003
|
+
for (const method of methods) {
|
|
6004
|
+
const methodScenarios = generateMethodScenarios(
|
|
6005
|
+
method,
|
|
6006
|
+
{ isEntity, hasTenant, hasFactoryMethod, hasSoftDelete },
|
|
6007
|
+
target,
|
|
6008
|
+
depth
|
|
6009
|
+
);
|
|
6010
|
+
scenarios.push(...methodScenarios);
|
|
6011
|
+
}
|
|
6012
|
+
if (isEntity && target === "entity") {
|
|
6013
|
+
scenarios.push(...generateEntityScenarios(content, hasTenant, hasSoftDelete, depth));
|
|
6014
|
+
}
|
|
6015
|
+
if (depth !== "basic") {
|
|
6016
|
+
scenarios.push(...generateSecurityScenarios(methods, hasTenant, target));
|
|
6017
|
+
}
|
|
6018
|
+
return scenarios;
|
|
6019
|
+
}
|
|
6020
|
+
function generateMethodScenarios(method, patterns, _target, depth) {
|
|
6021
|
+
const scenarios = [];
|
|
6022
|
+
const methodLower = method.name.toLowerCase();
|
|
6023
|
+
scenarios.push({
|
|
6024
|
+
methodName: method.name,
|
|
6025
|
+
scenarioName: "Happy Path",
|
|
6026
|
+
description: `Test ${method.name} with valid data`,
|
|
6027
|
+
type: "happy-path",
|
|
6028
|
+
priority: "critical",
|
|
6029
|
+
testMethodName: `${method.name}_WhenValidData_ShouldSucceed`,
|
|
6030
|
+
assertions: generateAssertions(method, "happy-path")
|
|
6031
|
+
});
|
|
6032
|
+
if (methodLower.includes("getbyid") || methodLower.includes("get")) {
|
|
6033
|
+
scenarios.push({
|
|
6034
|
+
methodName: method.name,
|
|
6035
|
+
scenarioName: "Not Found",
|
|
6036
|
+
description: `Test ${method.name} when entity does not exist`,
|
|
6037
|
+
type: "error-handling",
|
|
6038
|
+
priority: "high",
|
|
6039
|
+
testMethodName: `${method.name}_WhenNotExists_ShouldReturnNull`,
|
|
6040
|
+
assertions: ["result.Should().BeNull()"]
|
|
6041
|
+
});
|
|
6042
|
+
}
|
|
6043
|
+
if (methodLower.includes("create") || methodLower.includes("add")) {
|
|
6044
|
+
scenarios.push({
|
|
6045
|
+
methodName: method.name,
|
|
6046
|
+
scenarioName: "Validation Failure",
|
|
6047
|
+
description: `Test ${method.name} with invalid data`,
|
|
6048
|
+
type: "validation",
|
|
6049
|
+
priority: "high",
|
|
6050
|
+
testMethodName: `${method.name}_WhenInvalidData_ShouldThrow`,
|
|
6051
|
+
assertions: ["act.Should().ThrowAsync<ValidationException>()"]
|
|
6052
|
+
});
|
|
6053
|
+
if (patterns.hasTenant) {
|
|
6054
|
+
scenarios.push({
|
|
6055
|
+
methodName: method.name,
|
|
6056
|
+
scenarioName: "Missing Tenant",
|
|
6057
|
+
description: `Test ${method.name} without tenant context`,
|
|
6058
|
+
type: "security",
|
|
6059
|
+
priority: "critical",
|
|
6060
|
+
testMethodName: `${method.name}_WhenNoTenant_ShouldThrow`,
|
|
6061
|
+
assertions: ["act.Should().ThrowAsync<ArgumentException>()"]
|
|
6062
|
+
});
|
|
6063
|
+
}
|
|
6064
|
+
}
|
|
6065
|
+
if (methodLower.includes("update")) {
|
|
6066
|
+
scenarios.push({
|
|
6067
|
+
methodName: method.name,
|
|
6068
|
+
scenarioName: "Not Found",
|
|
6069
|
+
description: `Test ${method.name} when entity does not exist`,
|
|
6070
|
+
type: "error-handling",
|
|
6071
|
+
priority: "high",
|
|
6072
|
+
testMethodName: `${method.name}_WhenNotExists_ShouldThrow`,
|
|
6073
|
+
assertions: ["act.Should().ThrowAsync<InvalidOperationException>()"]
|
|
6074
|
+
});
|
|
6075
|
+
if (depth !== "basic") {
|
|
6076
|
+
scenarios.push({
|
|
6077
|
+
methodName: method.name,
|
|
6078
|
+
scenarioName: "Concurrent Update",
|
|
6079
|
+
description: `Test ${method.name} with concurrent modification`,
|
|
6080
|
+
type: "edge-case",
|
|
6081
|
+
priority: "medium",
|
|
6082
|
+
testMethodName: `${method.name}_WhenConcurrentUpdate_ShouldHandleConflict`,
|
|
6083
|
+
assertions: ["Should handle DbUpdateConcurrencyException"]
|
|
6084
|
+
});
|
|
6085
|
+
}
|
|
6086
|
+
}
|
|
6087
|
+
if (methodLower.includes("delete")) {
|
|
6088
|
+
scenarios.push({
|
|
6089
|
+
methodName: method.name,
|
|
6090
|
+
scenarioName: "Not Found",
|
|
6091
|
+
description: `Test ${method.name} when entity does not exist`,
|
|
6092
|
+
type: "error-handling",
|
|
6093
|
+
priority: "high",
|
|
6094
|
+
testMethodName: `${method.name}_WhenNotExists_ShouldReturnFalse`,
|
|
6095
|
+
assertions: ["result.Should().BeFalse()"]
|
|
6096
|
+
});
|
|
6097
|
+
if (patterns.hasSoftDelete) {
|
|
6098
|
+
scenarios.push({
|
|
6099
|
+
methodName: method.name,
|
|
6100
|
+
scenarioName: "Soft Delete",
|
|
6101
|
+
description: `Verify ${method.name} performs soft delete`,
|
|
6102
|
+
type: "happy-path",
|
|
6103
|
+
priority: "high",
|
|
6104
|
+
testMethodName: `${method.name}_WhenCalled_ShouldSoftDelete`,
|
|
6105
|
+
assertions: ["entity.IsDeleted.Should().BeTrue()", "entity.DeletedAt.Should().NotBeNull()"]
|
|
6106
|
+
});
|
|
6107
|
+
}
|
|
6108
|
+
}
|
|
6109
|
+
if (method.isAsync && depth !== "basic") {
|
|
6110
|
+
scenarios.push({
|
|
6111
|
+
methodName: method.name,
|
|
6112
|
+
scenarioName: "Cancellation",
|
|
6113
|
+
description: `Test ${method.name} with cancellation token`,
|
|
6114
|
+
type: "edge-case",
|
|
6115
|
+
priority: "low",
|
|
6116
|
+
testMethodName: `${method.name}_WhenCancelled_ShouldThrowOperationCancelled`,
|
|
6117
|
+
assertions: ["act.Should().ThrowAsync<OperationCanceledException>()"]
|
|
6118
|
+
});
|
|
6119
|
+
}
|
|
6120
|
+
return scenarios;
|
|
6121
|
+
}
|
|
6122
|
+
function generateEntityScenarios(content, hasTenant, hasSoftDelete, depth) {
|
|
6123
|
+
const scenarios = [];
|
|
6124
|
+
if (PATTERNS2.factoryMethod.test(content)) {
|
|
6125
|
+
scenarios.push({
|
|
6126
|
+
methodName: "Create",
|
|
6127
|
+
scenarioName: "Factory Method",
|
|
6128
|
+
description: "Test entity creation via factory method",
|
|
6129
|
+
type: "happy-path",
|
|
6130
|
+
priority: "critical",
|
|
6131
|
+
testMethodName: "Create_WhenValidData_ShouldCreateEntity",
|
|
6132
|
+
assertions: [
|
|
6133
|
+
"entity.Should().NotBeNull()",
|
|
6134
|
+
"entity.Id.Should().NotBeEmpty()",
|
|
6135
|
+
"entity.CreatedAt.Should().BeCloseTo(DateTime.UtcNow)"
|
|
6136
|
+
]
|
|
6137
|
+
});
|
|
6138
|
+
if (hasTenant) {
|
|
6139
|
+
scenarios.push({
|
|
6140
|
+
methodName: "Create",
|
|
6141
|
+
scenarioName: "Invalid Tenant",
|
|
6142
|
+
description: "Test entity creation with empty tenant ID",
|
|
6143
|
+
type: "validation",
|
|
6144
|
+
priority: "critical",
|
|
6145
|
+
testMethodName: "Create_WhenEmptyTenantId_ShouldThrow",
|
|
6146
|
+
assertions: ["act.Should().Throw<ArgumentException>()"]
|
|
6147
|
+
});
|
|
6148
|
+
}
|
|
6149
|
+
}
|
|
6150
|
+
if (hasSoftDelete) {
|
|
6151
|
+
scenarios.push({
|
|
6152
|
+
methodName: "SoftDelete",
|
|
6153
|
+
scenarioName: "Soft Delete",
|
|
6154
|
+
description: "Test soft delete sets IsDeleted and DeletedAt",
|
|
6155
|
+
type: "happy-path",
|
|
6156
|
+
priority: "high",
|
|
6157
|
+
testMethodName: "SoftDelete_WhenCalled_ShouldSetDeletedFields",
|
|
6158
|
+
assertions: [
|
|
6159
|
+
"entity.IsDeleted.Should().BeTrue()",
|
|
6160
|
+
"entity.DeletedAt.Should().NotBeNull()",
|
|
6161
|
+
"entity.DeletedBy.Should().Be(deletedBy)"
|
|
6162
|
+
]
|
|
6163
|
+
});
|
|
6164
|
+
if (depth !== "basic") {
|
|
6165
|
+
scenarios.push({
|
|
6166
|
+
methodName: "Restore",
|
|
6167
|
+
scenarioName: "Restore After Delete",
|
|
6168
|
+
description: "Test restoring a soft-deleted entity",
|
|
6169
|
+
type: "happy-path",
|
|
6170
|
+
priority: "medium",
|
|
6171
|
+
testMethodName: "Restore_WhenSoftDeleted_ShouldClearDeletedFields",
|
|
6172
|
+
assertions: [
|
|
6173
|
+
"entity.IsDeleted.Should().BeFalse()",
|
|
6174
|
+
"entity.DeletedAt.Should().BeNull()"
|
|
6175
|
+
]
|
|
6176
|
+
});
|
|
6177
|
+
}
|
|
6178
|
+
}
|
|
6179
|
+
if (depth !== "basic") {
|
|
6180
|
+
scenarios.push({
|
|
6181
|
+
methodName: "Update",
|
|
6182
|
+
scenarioName: "Audit Trail",
|
|
6183
|
+
description: "Test that Update sets audit fields",
|
|
6184
|
+
type: "happy-path",
|
|
6185
|
+
priority: "medium",
|
|
6186
|
+
testMethodName: "Update_WhenCalled_ShouldSetAuditFields",
|
|
6187
|
+
assertions: [
|
|
6188
|
+
"entity.UpdatedAt.Should().NotBeNull()",
|
|
6189
|
+
"entity.UpdatedBy.Should().Be(updatedBy)",
|
|
6190
|
+
"entity.CreatedAt.Should().Be(originalCreatedAt)"
|
|
6191
|
+
]
|
|
6192
|
+
});
|
|
6193
|
+
}
|
|
6194
|
+
return scenarios;
|
|
6195
|
+
}
|
|
6196
|
+
function generateSecurityScenarios(_methods, hasTenant, _target) {
|
|
6197
|
+
const scenarios = [];
|
|
6198
|
+
if (hasTenant) {
|
|
6199
|
+
scenarios.push({
|
|
6200
|
+
methodName: "TenantIsolation",
|
|
6201
|
+
scenarioName: "Cross-Tenant Access",
|
|
6202
|
+
description: "Test that entities from other tenants are not accessible",
|
|
6203
|
+
type: "security",
|
|
6204
|
+
priority: "critical",
|
|
6205
|
+
testMethodName: "GetById_WhenDifferentTenant_ShouldReturnNull",
|
|
6206
|
+
assertions: ["result.Should().BeNull()"]
|
|
6207
|
+
});
|
|
6208
|
+
}
|
|
6209
|
+
if (_target === "controller") {
|
|
6210
|
+
scenarios.push({
|
|
6211
|
+
methodName: "Authorization",
|
|
6212
|
+
scenarioName: "Unauthorized Access",
|
|
6213
|
+
description: "Test that unauthorized requests are rejected",
|
|
6214
|
+
type: "security",
|
|
6215
|
+
priority: "critical",
|
|
6216
|
+
testMethodName: "GetAll_WhenUnauthorized_ShouldReturn401",
|
|
6217
|
+
assertions: ["response.StatusCode.Should().Be(HttpStatusCode.Unauthorized)"]
|
|
6218
|
+
});
|
|
6219
|
+
scenarios.push({
|
|
6220
|
+
methodName: "InputValidation",
|
|
6221
|
+
scenarioName: "SQL Injection",
|
|
6222
|
+
description: "Test that SQL injection attempts are handled",
|
|
6223
|
+
type: "security",
|
|
6224
|
+
priority: "high",
|
|
6225
|
+
testMethodName: "Create_WhenSqlInjectionAttempt_ShouldSanitize",
|
|
6226
|
+
assertions: ["Should not execute malicious SQL"]
|
|
6227
|
+
});
|
|
6228
|
+
scenarios.push({
|
|
6229
|
+
methodName: "InputValidation",
|
|
6230
|
+
scenarioName: "XSS Prevention",
|
|
6231
|
+
description: "Test that XSS attempts are handled",
|
|
6232
|
+
type: "security",
|
|
6233
|
+
priority: "high",
|
|
6234
|
+
testMethodName: "Create_WhenXssAttempt_ShouldSanitize",
|
|
6235
|
+
assertions: ["Should not store or return script tags"]
|
|
6236
|
+
});
|
|
6237
|
+
}
|
|
6238
|
+
return scenarios;
|
|
6239
|
+
}
|
|
6240
|
+
function generateAssertions(method, type) {
|
|
6241
|
+
const assertions = [];
|
|
6242
|
+
if (type === "happy-path") {
|
|
6243
|
+
if (method.returnType === "void" || method.returnType === "Task") {
|
|
6244
|
+
assertions.push("Should complete without exception");
|
|
6245
|
+
} else if (method.returnType.includes("bool") || method.returnType === "Boolean") {
|
|
6246
|
+
assertions.push("result.Should().BeTrue()");
|
|
6247
|
+
} else {
|
|
6248
|
+
assertions.push("result.Should().NotBeNull()");
|
|
6249
|
+
}
|
|
6250
|
+
}
|
|
6251
|
+
return assertions;
|
|
6252
|
+
}
|
|
6253
|
+
function calculateCoverageDistribution(result) {
|
|
6254
|
+
const total = result.scenarios.length;
|
|
6255
|
+
if (total === 0) return;
|
|
6256
|
+
const counts = {
|
|
6257
|
+
happyPath: result.scenarios.filter((s) => s.type === "happy-path").length,
|
|
6258
|
+
validation: result.scenarios.filter((s) => s.type === "validation").length,
|
|
6259
|
+
errorHandling: result.scenarios.filter((s) => s.type === "error-handling").length,
|
|
6260
|
+
security: result.scenarios.filter((s) => s.type === "security").length,
|
|
6261
|
+
edgeCases: result.scenarios.filter((s) => s.type === "edge-case" || s.type === "performance").length
|
|
6262
|
+
};
|
|
6263
|
+
result.coverage = {
|
|
6264
|
+
happyPath: Math.round(counts.happyPath / total * 100),
|
|
6265
|
+
validation: Math.round(counts.validation / total * 100),
|
|
6266
|
+
errorHandling: Math.round(counts.errorHandling / total * 100),
|
|
6267
|
+
security: Math.round(counts.security / total * 100),
|
|
6268
|
+
edgeCases: Math.round(counts.edgeCases / total * 100)
|
|
6269
|
+
};
|
|
6270
|
+
}
|
|
6271
|
+
function formatScenariosResult(result, depth) {
|
|
6272
|
+
const lines = [];
|
|
6273
|
+
lines.push(`# Test Scenarios for ${result.target}`);
|
|
6274
|
+
lines.push("");
|
|
6275
|
+
lines.push(`> Analysis depth: **${depth}**`);
|
|
6276
|
+
lines.push("");
|
|
6277
|
+
if (result.detectedMethods.length > 0) {
|
|
6278
|
+
lines.push("## Detected Methods");
|
|
6279
|
+
lines.push("");
|
|
6280
|
+
lines.push("| Method | Return Type | Parameters | Async |");
|
|
6281
|
+
lines.push("|--------|-------------|------------|-------|");
|
|
6282
|
+
for (const method of result.detectedMethods) {
|
|
6283
|
+
const params = method.parameters.length > 0 ? method.parameters.join(", ") : "none";
|
|
6284
|
+
lines.push(`| ${method.name} | ${method.returnType} | ${params} | ${method.isAsync ? "Yes" : "No"} |`);
|
|
6285
|
+
}
|
|
6286
|
+
lines.push("");
|
|
6287
|
+
}
|
|
6288
|
+
lines.push("## Scenario Coverage Distribution");
|
|
6289
|
+
lines.push("");
|
|
6290
|
+
lines.push("```");
|
|
6291
|
+
lines.push(`Happy Path: ${"\u2588".repeat(Math.floor(result.coverage.happyPath / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.happyPath / 5))} ${result.coverage.happyPath}%`);
|
|
6292
|
+
lines.push(`Validation: ${"\u2588".repeat(Math.floor(result.coverage.validation / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.validation / 5))} ${result.coverage.validation}%`);
|
|
6293
|
+
lines.push(`Error Handling: ${"\u2588".repeat(Math.floor(result.coverage.errorHandling / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.errorHandling / 5))} ${result.coverage.errorHandling}%`);
|
|
6294
|
+
lines.push(`Security: ${"\u2588".repeat(Math.floor(result.coverage.security / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.security / 5))} ${result.coverage.security}%`);
|
|
6295
|
+
lines.push(`Edge Cases: ${"\u2588".repeat(Math.floor(result.coverage.edgeCases / 5))}${"\u2591".repeat(20 - Math.floor(result.coverage.edgeCases / 5))} ${result.coverage.edgeCases}%`);
|
|
6296
|
+
lines.push("```");
|
|
6297
|
+
lines.push("");
|
|
6298
|
+
lines.push("## Suggested Test Scenarios");
|
|
6299
|
+
lines.push("");
|
|
6300
|
+
const priorities = ["critical", "high", "medium", "low"];
|
|
6301
|
+
for (const priority of priorities) {
|
|
6302
|
+
const scenarios = result.scenarios.filter((s) => s.priority === priority);
|
|
6303
|
+
if (scenarios.length === 0) continue;
|
|
6304
|
+
const emoji = priority === "critical" ? "\u{1F534}" : priority === "high" ? "\u{1F7E0}" : priority === "medium" ? "\u{1F7E1}" : "\u{1F7E2}";
|
|
6305
|
+
lines.push(`### ${emoji} ${priority.charAt(0).toUpperCase() + priority.slice(1)} Priority`);
|
|
6306
|
+
lines.push("");
|
|
6307
|
+
for (const scenario of scenarios) {
|
|
6308
|
+
const typeEmoji = getTypeEmoji(scenario.type);
|
|
6309
|
+
lines.push(`#### ${typeEmoji} ${scenario.methodName} - ${scenario.scenarioName}`);
|
|
6310
|
+
lines.push("");
|
|
6311
|
+
lines.push(`**Description:** ${scenario.description}`);
|
|
6312
|
+
lines.push("");
|
|
6313
|
+
lines.push(`**Test Method:** \`${scenario.testMethodName}\``);
|
|
6314
|
+
lines.push("");
|
|
6315
|
+
if (scenario.assertions.length > 0) {
|
|
6316
|
+
lines.push("**Assertions:**");
|
|
6317
|
+
for (const assertion of scenario.assertions) {
|
|
6318
|
+
lines.push(`- \`${assertion}\``);
|
|
6319
|
+
}
|
|
6320
|
+
lines.push("");
|
|
6321
|
+
}
|
|
6322
|
+
}
|
|
6323
|
+
}
|
|
6324
|
+
lines.push("## Quick Test Generation");
|
|
6325
|
+
lines.push("");
|
|
6326
|
+
lines.push("Generate tests for this component:");
|
|
6327
|
+
lines.push("");
|
|
6328
|
+
lines.push("```");
|
|
6329
|
+
lines.push(`scaffold_tests(target: "${result.targetType}", name: "${result.target}", testTypes: ["unit", "security"])`);
|
|
6330
|
+
lines.push("```");
|
|
6331
|
+
lines.push("");
|
|
6332
|
+
return lines.join("\n");
|
|
6333
|
+
}
|
|
6334
|
+
function getTypeEmoji(type) {
|
|
6335
|
+
switch (type) {
|
|
6336
|
+
case "happy-path":
|
|
6337
|
+
return "\u2705";
|
|
6338
|
+
case "validation":
|
|
6339
|
+
return "\u{1F50D}";
|
|
6340
|
+
case "error-handling":
|
|
6341
|
+
return "\u26A0\uFE0F";
|
|
6342
|
+
case "security":
|
|
6343
|
+
return "\u{1F512}";
|
|
6344
|
+
case "edge-case":
|
|
6345
|
+
return "\u{1F9EA}";
|
|
6346
|
+
case "performance":
|
|
6347
|
+
return "\u26A1";
|
|
6348
|
+
default:
|
|
6349
|
+
return "\u{1F4CB}";
|
|
6350
|
+
}
|
|
6351
|
+
}
|
|
6352
|
+
|
|
6353
|
+
// src/tools/scaffold-api-client.ts
|
|
6354
|
+
import path14 from "path";
|
|
6355
|
+
var scaffoldApiClientTool = {
|
|
6356
|
+
name: "scaffold_api_client",
|
|
6357
|
+
description: `Generate TypeScript API client with NavRoute integration.
|
|
6358
|
+
|
|
6359
|
+
Creates:
|
|
6360
|
+
- Type-safe API service with CRUD methods
|
|
6361
|
+
- TypeScript interfaces for request/response
|
|
6362
|
+
- React Query hook (optional)
|
|
6363
|
+
- Integration with navRoutes.generated.ts registry
|
|
6364
|
+
|
|
6365
|
+
Example:
|
|
6366
|
+
scaffold_api_client navRoute="platform.administration.users" name="User"
|
|
6367
|
+
|
|
6368
|
+
The generated client automatically resolves the API path from the NavRoute registry,
|
|
6369
|
+
ensuring frontend routes stay synchronized with backend NavRoute attributes.`,
|
|
6370
|
+
inputSchema: {
|
|
6371
|
+
type: "object",
|
|
6372
|
+
properties: {
|
|
6373
|
+
navRoute: {
|
|
6374
|
+
type: "string",
|
|
6375
|
+
description: 'NavRoute path (e.g., "platform.administration.users")'
|
|
6376
|
+
},
|
|
6377
|
+
name: {
|
|
6378
|
+
type: "string",
|
|
6379
|
+
description: 'Entity name in PascalCase (e.g., "User", "Order")'
|
|
6380
|
+
},
|
|
6381
|
+
methods: {
|
|
6382
|
+
type: "array",
|
|
6383
|
+
items: {
|
|
6384
|
+
type: "string",
|
|
6385
|
+
enum: ["getAll", "getById", "create", "update", "delete", "search", "export"]
|
|
6386
|
+
},
|
|
6387
|
+
default: ["getAll", "getById", "create", "update", "delete"],
|
|
6388
|
+
description: "API methods to generate"
|
|
6389
|
+
},
|
|
6390
|
+
options: {
|
|
6391
|
+
type: "object",
|
|
6392
|
+
properties: {
|
|
6393
|
+
outputPath: { type: "string", description: "Custom output path" },
|
|
6394
|
+
includeTypes: { type: "boolean", default: true, description: "Generate TypeScript types" },
|
|
6395
|
+
includeHook: { type: "boolean", default: true, description: "Generate React Query hook" },
|
|
6396
|
+
dryRun: { type: "boolean", default: false, description: "Preview without writing" }
|
|
6397
|
+
}
|
|
6398
|
+
}
|
|
6399
|
+
},
|
|
6400
|
+
required: ["navRoute", "name"]
|
|
6401
|
+
}
|
|
6402
|
+
};
|
|
6403
|
+
async function handleScaffoldApiClient(args, config) {
|
|
6404
|
+
const input = ScaffoldApiClientInputSchema.parse(args);
|
|
6405
|
+
logger.info("Scaffolding API client", { navRoute: input.navRoute, name: input.name });
|
|
6406
|
+
const result = await scaffoldApiClient(input, config);
|
|
6407
|
+
return formatResult4(result, input);
|
|
6408
|
+
}
|
|
6409
|
+
async function scaffoldApiClient(input, config) {
|
|
6410
|
+
const result = {
|
|
6411
|
+
success: true,
|
|
6412
|
+
files: [],
|
|
6413
|
+
instructions: []
|
|
6414
|
+
};
|
|
6415
|
+
const { navRoute, name, methods, options } = input;
|
|
6416
|
+
const dryRun = options?.dryRun ?? false;
|
|
6417
|
+
const includeTypes = options?.includeTypes ?? true;
|
|
6418
|
+
const includeHook = options?.includeHook ?? true;
|
|
6419
|
+
const nameLower = name.charAt(0).toLowerCase() + name.slice(1);
|
|
6420
|
+
const apiPath = navRouteToApiPath(navRoute);
|
|
6421
|
+
const projectRoot = config.smartstack.projectPath;
|
|
6422
|
+
const structure = await findSmartStackStructure(projectRoot);
|
|
6423
|
+
const webPath = structure.web || path14.join(projectRoot, "web", "smartstack-web");
|
|
6424
|
+
const servicesPath = options?.outputPath || path14.join(webPath, "src", "services", "api");
|
|
6425
|
+
const hooksPath = path14.join(webPath, "src", "hooks");
|
|
6426
|
+
const typesPath = path14.join(webPath, "src", "types");
|
|
6427
|
+
const apiClientContent = generateApiClient(name, nameLower, navRoute, apiPath, methods);
|
|
6428
|
+
const apiClientFile = path14.join(servicesPath, `${nameLower}.ts`);
|
|
6429
|
+
if (!dryRun) {
|
|
6430
|
+
await ensureDirectory(servicesPath);
|
|
6431
|
+
await writeText(apiClientFile, apiClientContent);
|
|
6432
|
+
}
|
|
6433
|
+
result.files.push({ path: apiClientFile, content: apiClientContent, type: "created" });
|
|
6434
|
+
if (includeTypes) {
|
|
6435
|
+
const typesContent = generateTypes(name);
|
|
6436
|
+
const typesFile = path14.join(typesPath, `${nameLower}.ts`);
|
|
6437
|
+
if (!dryRun) {
|
|
6438
|
+
await ensureDirectory(typesPath);
|
|
6439
|
+
await writeText(typesFile, typesContent);
|
|
6440
|
+
}
|
|
6441
|
+
result.files.push({ path: typesFile, content: typesContent, type: "created" });
|
|
6442
|
+
}
|
|
6443
|
+
if (includeHook) {
|
|
6444
|
+
const hookContent = generateHook(name, nameLower, methods);
|
|
6445
|
+
const hookFile = path14.join(hooksPath, `use${name}.ts`);
|
|
6446
|
+
if (!dryRun) {
|
|
6447
|
+
await ensureDirectory(hooksPath);
|
|
6448
|
+
await writeText(hookFile, hookContent);
|
|
6449
|
+
}
|
|
6450
|
+
result.files.push({ path: hookFile, content: hookContent, type: "created" });
|
|
6451
|
+
}
|
|
6452
|
+
result.instructions.push(`Import the API client: import { ${nameLower}Api } from './services/api/${nameLower}';`);
|
|
6453
|
+
if (includeHook) {
|
|
6454
|
+
result.instructions.push(`Import the hook: import { use${name}, use${name}List } from './hooks/use${name}';`);
|
|
6455
|
+
}
|
|
6456
|
+
result.instructions.push(`Ensure navRoutes.generated.ts includes route: "${navRoute}"`);
|
|
6457
|
+
return result;
|
|
6458
|
+
}
|
|
6459
|
+
function navRouteToApiPath(navRoute) {
|
|
6460
|
+
return `/api/${navRoute.replace(/\./g, "/")}`;
|
|
6461
|
+
}
|
|
6462
|
+
function generateApiClient(name, nameLower, navRoute, apiPath, methods) {
|
|
6463
|
+
const template = `/**
|
|
6464
|
+
* ${name} API Client
|
|
6465
|
+
*
|
|
6466
|
+
* Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY
|
|
6467
|
+
* NavRoute: ${navRoute}
|
|
6468
|
+
* API Path: ${apiPath}
|
|
6469
|
+
*/
|
|
6470
|
+
|
|
6471
|
+
import { getRoute } from '../routes/navRoutes.generated';
|
|
6472
|
+
import { apiClient } from '../lib/apiClient';
|
|
6473
|
+
import type {
|
|
6474
|
+
${name},
|
|
6475
|
+
${name}CreateRequest,
|
|
6476
|
+
${name}UpdateRequest,
|
|
6477
|
+
${name}ListResponse,
|
|
6478
|
+
PaginatedRequest,
|
|
6479
|
+
PaginatedResponse
|
|
6480
|
+
} from '../types/${nameLower}';
|
|
6481
|
+
|
|
6482
|
+
const ROUTE = getRoute('${navRoute}');
|
|
6483
|
+
|
|
6484
|
+
export const ${nameLower}Api = {
|
|
6485
|
+
${methods.includes("getAll") ? ` /**
|
|
6486
|
+
* Get all ${name}s with pagination
|
|
6487
|
+
*/
|
|
6488
|
+
async getAll(params?: PaginatedRequest): Promise<PaginatedResponse<${name}>> {
|
|
6489
|
+
const response = await apiClient.get<${name}ListResponse>(ROUTE.api, { params });
|
|
6490
|
+
return response.data;
|
|
6491
|
+
},
|
|
6492
|
+
` : ""}
|
|
6493
|
+
${methods.includes("getById") ? ` /**
|
|
6494
|
+
* Get ${name} by ID
|
|
6495
|
+
*/
|
|
6496
|
+
async getById(id: string): Promise<${name}> {
|
|
6497
|
+
const response = await apiClient.get<${name}>(\`\${ROUTE.api}/\${id}\`);
|
|
6498
|
+
return response.data;
|
|
6499
|
+
},
|
|
6500
|
+
` : ""}
|
|
6501
|
+
${methods.includes("create") ? ` /**
|
|
6502
|
+
* Create new ${name}
|
|
6503
|
+
*/
|
|
6504
|
+
async create(data: ${name}CreateRequest): Promise<${name}> {
|
|
6505
|
+
const response = await apiClient.post<${name}>(ROUTE.api, data);
|
|
6506
|
+
return response.data;
|
|
6507
|
+
},
|
|
6508
|
+
` : ""}
|
|
6509
|
+
${methods.includes("update") ? ` /**
|
|
6510
|
+
* Update existing ${name}
|
|
6511
|
+
*/
|
|
6512
|
+
async update(id: string, data: ${name}UpdateRequest): Promise<${name}> {
|
|
6513
|
+
const response = await apiClient.put<${name}>(\`\${ROUTE.api}/\${id}\`, data);
|
|
6514
|
+
return response.data;
|
|
6515
|
+
},
|
|
6516
|
+
` : ""}
|
|
6517
|
+
${methods.includes("delete") ? ` /**
|
|
6518
|
+
* Delete ${name}
|
|
6519
|
+
*/
|
|
6520
|
+
async delete(id: string): Promise<void> {
|
|
6521
|
+
await apiClient.delete(\`\${ROUTE.api}/\${id}\`);
|
|
6522
|
+
},
|
|
6523
|
+
` : ""}
|
|
6524
|
+
${methods.includes("search") ? ` /**
|
|
6525
|
+
* Search ${name}s
|
|
6526
|
+
*/
|
|
6527
|
+
async search(query: string, params?: PaginatedRequest): Promise<PaginatedResponse<${name}>> {
|
|
6528
|
+
const response = await apiClient.get<${name}ListResponse>(\`\${ROUTE.api}/search\`, {
|
|
6529
|
+
params: { q: query, ...params }
|
|
6530
|
+
});
|
|
6531
|
+
return response.data;
|
|
6532
|
+
},
|
|
6533
|
+
` : ""}
|
|
6534
|
+
${methods.includes("export") ? ` /**
|
|
6535
|
+
* Export ${name}s to file
|
|
6536
|
+
*/
|
|
6537
|
+
async export(format: 'csv' | 'xlsx' | 'pdf' = 'xlsx'): Promise<Blob> {
|
|
6538
|
+
const response = await apiClient.get(\`\${ROUTE.api}/export\`, {
|
|
6539
|
+
params: { format },
|
|
6540
|
+
responseType: 'blob'
|
|
6541
|
+
});
|
|
6542
|
+
return response.data;
|
|
6543
|
+
},
|
|
6544
|
+
` : ""}
|
|
6545
|
+
/**
|
|
6546
|
+
* Get the NavRoute for this API
|
|
6547
|
+
*/
|
|
6548
|
+
getRoute() {
|
|
6549
|
+
return ROUTE;
|
|
6550
|
+
},
|
|
6551
|
+
};
|
|
6552
|
+
|
|
6553
|
+
export default ${nameLower}Api;
|
|
6554
|
+
`;
|
|
6555
|
+
return template;
|
|
6556
|
+
}
|
|
6557
|
+
function generateTypes(name) {
|
|
6558
|
+
return `/**
|
|
6559
|
+
* ${name} Types
|
|
6560
|
+
*
|
|
6561
|
+
* Auto-generated by SmartStack MCP - Customize as needed
|
|
6562
|
+
*/
|
|
6563
|
+
|
|
6564
|
+
export interface ${name} {
|
|
6565
|
+
id: string;
|
|
6566
|
+
code: string;
|
|
6567
|
+
name?: string;
|
|
6568
|
+
description?: string;
|
|
6569
|
+
isActive: boolean;
|
|
6570
|
+
createdAt: string;
|
|
6571
|
+
createdBy: string;
|
|
6572
|
+
updatedAt?: string;
|
|
6573
|
+
updatedBy?: string;
|
|
6574
|
+
}
|
|
6575
|
+
|
|
6576
|
+
export interface ${name}CreateRequest {
|
|
6577
|
+
code: string;
|
|
6578
|
+
name?: string;
|
|
6579
|
+
description?: string;
|
|
6580
|
+
}
|
|
6581
|
+
|
|
6582
|
+
export interface ${name}UpdateRequest {
|
|
6583
|
+
code?: string;
|
|
6584
|
+
name?: string;
|
|
6585
|
+
description?: string;
|
|
6586
|
+
isActive?: boolean;
|
|
6587
|
+
}
|
|
6588
|
+
|
|
6589
|
+
export interface ${name}ListResponse {
|
|
6590
|
+
items: ${name}[];
|
|
6591
|
+
totalCount: number;
|
|
6592
|
+
pageSize: number;
|
|
6593
|
+
currentPage: number;
|
|
6594
|
+
totalPages: number;
|
|
6595
|
+
}
|
|
6596
|
+
|
|
6597
|
+
export interface PaginatedRequest {
|
|
6598
|
+
page?: number;
|
|
6599
|
+
pageSize?: number;
|
|
6600
|
+
sortBy?: string;
|
|
6601
|
+
sortDirection?: 'asc' | 'desc';
|
|
6602
|
+
filter?: string;
|
|
6603
|
+
}
|
|
6604
|
+
|
|
6605
|
+
export interface PaginatedResponse<T> {
|
|
6606
|
+
items: T[];
|
|
6607
|
+
totalCount: number;
|
|
6608
|
+
pageSize: number;
|
|
6609
|
+
currentPage: number;
|
|
6610
|
+
totalPages: number;
|
|
6611
|
+
}
|
|
6612
|
+
`;
|
|
6613
|
+
}
|
|
6614
|
+
function generateHook(name, nameLower, methods) {
|
|
6615
|
+
return `/**
|
|
6616
|
+
* ${name} React Query Hooks
|
|
6617
|
+
*
|
|
6618
|
+
* Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY
|
|
6619
|
+
*/
|
|
6620
|
+
|
|
6621
|
+
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
|
6622
|
+
import { ${nameLower}Api } from '../services/api/${nameLower}';
|
|
6623
|
+
import type { ${name}, ${name}CreateRequest, ${name}UpdateRequest, PaginatedRequest, PaginatedResponse } from '../types/${nameLower}';
|
|
6624
|
+
|
|
6625
|
+
const QUERY_KEY = '${nameLower}s';
|
|
6626
|
+
|
|
6627
|
+
${methods.includes("getAll") ? `/**
|
|
6628
|
+
* Hook to fetch paginated ${name} list
|
|
6629
|
+
*/
|
|
6630
|
+
export function use${name}List(
|
|
6631
|
+
params?: PaginatedRequest,
|
|
6632
|
+
options?: Omit<UseQueryOptions<PaginatedResponse<${name}>>, 'queryKey' | 'queryFn'>
|
|
6633
|
+
) {
|
|
6634
|
+
return useQuery({
|
|
6635
|
+
queryKey: [QUERY_KEY, 'list', params],
|
|
6636
|
+
queryFn: () => ${nameLower}Api.getAll(params),
|
|
6637
|
+
...options,
|
|
6638
|
+
});
|
|
6639
|
+
}
|
|
6640
|
+
` : ""}
|
|
6641
|
+
${methods.includes("getById") ? `/**
|
|
6642
|
+
* Hook to fetch single ${name} by ID
|
|
6643
|
+
*/
|
|
6644
|
+
export function use${name}(
|
|
6645
|
+
id: string | undefined,
|
|
6646
|
+
options?: Omit<UseQueryOptions<${name}>, 'queryKey' | 'queryFn'>
|
|
6647
|
+
) {
|
|
6648
|
+
return useQuery({
|
|
6649
|
+
queryKey: [QUERY_KEY, 'detail', id],
|
|
6650
|
+
queryFn: () => ${nameLower}Api.getById(id!),
|
|
6651
|
+
enabled: !!id,
|
|
6652
|
+
...options,
|
|
6653
|
+
});
|
|
6654
|
+
}
|
|
6655
|
+
` : ""}
|
|
6656
|
+
${methods.includes("create") ? `/**
|
|
6657
|
+
* Hook to create new ${name}
|
|
6658
|
+
*/
|
|
6659
|
+
export function use${name}Create(
|
|
6660
|
+
options?: UseMutationOptions<${name}, Error, ${name}CreateRequest>
|
|
6661
|
+
) {
|
|
6662
|
+
const queryClient = useQueryClient();
|
|
6663
|
+
|
|
6664
|
+
return useMutation({
|
|
6665
|
+
mutationFn: (data: ${name}CreateRequest) => ${nameLower}Api.create(data),
|
|
6666
|
+
onSuccess: () => {
|
|
6667
|
+
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
|
6668
|
+
},
|
|
6669
|
+
...options,
|
|
6670
|
+
});
|
|
6671
|
+
}
|
|
6672
|
+
` : ""}
|
|
6673
|
+
${methods.includes("update") ? `/**
|
|
6674
|
+
* Hook to update existing ${name}
|
|
6675
|
+
*/
|
|
6676
|
+
export function use${name}Update(
|
|
6677
|
+
options?: UseMutationOptions<${name}, Error, { id: string; data: ${name}UpdateRequest }>
|
|
6678
|
+
) {
|
|
6679
|
+
const queryClient = useQueryClient();
|
|
6680
|
+
|
|
6681
|
+
return useMutation({
|
|
6682
|
+
mutationFn: ({ id, data }) => ${nameLower}Api.update(id, data),
|
|
6683
|
+
onSuccess: (_, { id }) => {
|
|
6684
|
+
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
|
6685
|
+
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', id] });
|
|
6686
|
+
},
|
|
6687
|
+
...options,
|
|
6688
|
+
});
|
|
6689
|
+
}
|
|
6690
|
+
` : ""}
|
|
6691
|
+
${methods.includes("delete") ? `/**
|
|
6692
|
+
* Hook to delete ${name}
|
|
6693
|
+
*/
|
|
6694
|
+
export function use${name}Delete(
|
|
6695
|
+
options?: UseMutationOptions<void, Error, string>
|
|
6696
|
+
) {
|
|
6697
|
+
const queryClient = useQueryClient();
|
|
6698
|
+
|
|
6699
|
+
return useMutation({
|
|
6700
|
+
mutationFn: (id: string) => ${nameLower}Api.delete(id),
|
|
6701
|
+
onSuccess: () => {
|
|
6702
|
+
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
|
6703
|
+
},
|
|
6704
|
+
...options,
|
|
6705
|
+
});
|
|
6706
|
+
}
|
|
6707
|
+
` : ""}
|
|
6708
|
+
${methods.includes("search") ? `/**
|
|
6709
|
+
* Hook to search ${name}s
|
|
6710
|
+
*/
|
|
6711
|
+
export function use${name}Search(
|
|
6712
|
+
query: string,
|
|
6713
|
+
params?: PaginatedRequest,
|
|
6714
|
+
options?: Omit<UseQueryOptions<PaginatedResponse<${name}>>, 'queryKey' | 'queryFn'>
|
|
6715
|
+
) {
|
|
6716
|
+
return useQuery({
|
|
6717
|
+
queryKey: [QUERY_KEY, 'search', query, params],
|
|
6718
|
+
queryFn: () => ${nameLower}Api.search(query, params),
|
|
6719
|
+
enabled: query.length >= 2,
|
|
6720
|
+
...options,
|
|
6721
|
+
});
|
|
6722
|
+
}
|
|
6723
|
+
` : ""}
|
|
6724
|
+
`;
|
|
6725
|
+
}
|
|
6726
|
+
function formatResult4(result, input) {
|
|
6727
|
+
const lines = [];
|
|
6728
|
+
lines.push(`# Scaffold API Client: ${input.name}`);
|
|
6729
|
+
lines.push("");
|
|
6730
|
+
if (input.options?.dryRun) {
|
|
6731
|
+
lines.push("> **DRY RUN** - No files were written");
|
|
6732
|
+
lines.push("");
|
|
6733
|
+
}
|
|
6734
|
+
lines.push(`## NavRoute Integration`);
|
|
6735
|
+
lines.push("");
|
|
6736
|
+
lines.push(`- **NavRoute**: \`${input.navRoute}\``);
|
|
6737
|
+
lines.push(`- **API Path**: \`${navRouteToApiPath(input.navRoute)}\``);
|
|
6738
|
+
lines.push(`- **Methods**: ${input.methods.join(", ")}`);
|
|
6739
|
+
lines.push("");
|
|
6740
|
+
lines.push("## Generated Files");
|
|
6741
|
+
lines.push("");
|
|
6742
|
+
for (const file of result.files) {
|
|
6743
|
+
const relativePath = file.path.replace(/\\/g, "/").split("/src/").pop() || file.path;
|
|
6744
|
+
lines.push(`### ${relativePath}`);
|
|
6745
|
+
lines.push("");
|
|
6746
|
+
lines.push("```typescript");
|
|
6747
|
+
lines.push(file.content.substring(0, 1500) + (file.content.length > 1500 ? "\n// ... (truncated)" : ""));
|
|
6748
|
+
lines.push("```");
|
|
6749
|
+
lines.push("");
|
|
6750
|
+
}
|
|
6751
|
+
lines.push("## Next Steps");
|
|
6752
|
+
lines.push("");
|
|
6753
|
+
for (const instruction of result.instructions) {
|
|
6754
|
+
lines.push(`- ${instruction}`);
|
|
6755
|
+
}
|
|
6756
|
+
lines.push("");
|
|
6757
|
+
lines.push("## Required Setup");
|
|
6758
|
+
lines.push("");
|
|
6759
|
+
lines.push("1. Ensure `navRoutes.generated.ts` exists (run `scaffold_routes`)");
|
|
6760
|
+
lines.push("2. Configure `apiClient` with base URL and auth interceptors");
|
|
6761
|
+
lines.push("3. Install dependencies: `npm install @tanstack/react-query axios`");
|
|
6762
|
+
return lines.join("\n");
|
|
6763
|
+
}
|
|
6764
|
+
|
|
6765
|
+
// src/tools/scaffold-routes.ts
|
|
6766
|
+
import path15 from "path";
|
|
6767
|
+
import { glob as glob2 } from "glob";
|
|
6768
|
+
var scaffoldRoutesTool = {
|
|
6769
|
+
name: "scaffold_routes",
|
|
6770
|
+
description: `Generate React Router configuration from backend NavRoute attributes.
|
|
6771
|
+
|
|
6772
|
+
Creates:
|
|
6773
|
+
- navRoutes.generated.ts: Registry of all routes with API paths and permissions
|
|
6774
|
+
- routes.tsx: React Router configuration with nested routes
|
|
6775
|
+
- Layout components (optional)
|
|
6776
|
+
|
|
6777
|
+
Example:
|
|
6778
|
+
scaffold_routes source="controllers" scope="all"
|
|
6779
|
+
|
|
6780
|
+
Scans backend controllers for [NavRoute("context.application.module")] attributes
|
|
6781
|
+
and generates corresponding frontend routing infrastructure.`,
|
|
6782
|
+
inputSchema: {
|
|
6783
|
+
type: "object",
|
|
6784
|
+
properties: {
|
|
6785
|
+
source: {
|
|
6786
|
+
type: "string",
|
|
6787
|
+
enum: ["controllers", "navigation", "manual"],
|
|
6788
|
+
default: "controllers",
|
|
6789
|
+
description: "Source for route discovery"
|
|
6790
|
+
},
|
|
6791
|
+
scope: {
|
|
6792
|
+
type: "string",
|
|
6793
|
+
enum: ["all", "platform", "business", "extensions"],
|
|
6794
|
+
default: "all",
|
|
6795
|
+
description: "Scope of routes to generate"
|
|
6796
|
+
},
|
|
6797
|
+
options: {
|
|
6798
|
+
type: "object",
|
|
6799
|
+
properties: {
|
|
6800
|
+
outputPath: { type: "string", description: "Custom output path" },
|
|
6801
|
+
includeLayouts: { type: "boolean", default: true },
|
|
6802
|
+
includeGuards: { type: "boolean", default: true },
|
|
6803
|
+
generateRegistry: { type: "boolean", default: true },
|
|
6804
|
+
dryRun: { type: "boolean", default: false }
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
6807
|
+
}
|
|
6808
|
+
}
|
|
6809
|
+
};
|
|
6810
|
+
async function handleScaffoldRoutes(args, config) {
|
|
6811
|
+
const input = ScaffoldRoutesInputSchema.parse(args);
|
|
6812
|
+
logger.info("Scaffolding routes", { source: input.source, scope: input.scope });
|
|
6813
|
+
const result = await scaffoldRoutes(input, config);
|
|
6814
|
+
return formatResult5(result, input);
|
|
6815
|
+
}
|
|
6816
|
+
async function scaffoldRoutes(input, config) {
|
|
6817
|
+
const result = {
|
|
6818
|
+
success: true,
|
|
6819
|
+
files: [],
|
|
6820
|
+
instructions: []
|
|
6821
|
+
};
|
|
6822
|
+
const { source, scope, options } = input;
|
|
6823
|
+
const dryRun = options?.dryRun ?? false;
|
|
6824
|
+
const includeLayouts = options?.includeLayouts ?? true;
|
|
6825
|
+
const includeGuards = options?.includeGuards ?? true;
|
|
6826
|
+
const generateRegistry = options?.generateRegistry ?? true;
|
|
6827
|
+
const projectRoot = config.smartstack.projectPath;
|
|
6828
|
+
const structure = await findSmartStackStructure(projectRoot);
|
|
6829
|
+
const webPath = structure.web || path15.join(projectRoot, "web", "smartstack-web");
|
|
6830
|
+
const routesPath = options?.outputPath || path15.join(webPath, "src", "routes");
|
|
6831
|
+
const navRoutes = await discoverNavRoutes(structure, scope);
|
|
6832
|
+
if (navRoutes.length === 0) {
|
|
6833
|
+
result.success = false;
|
|
6834
|
+
result.instructions.push("No NavRoute attributes found in controllers");
|
|
6835
|
+
return result;
|
|
6836
|
+
}
|
|
6837
|
+
if (generateRegistry) {
|
|
6838
|
+
const registryContent = generateNavRouteRegistry(navRoutes);
|
|
6839
|
+
const registryFile = path15.join(routesPath, "navRoutes.generated.ts");
|
|
6840
|
+
if (!dryRun) {
|
|
6841
|
+
await ensureDirectory(routesPath);
|
|
6842
|
+
await writeText(registryFile, registryContent);
|
|
6843
|
+
}
|
|
6844
|
+
result.files.push({ path: registryFile, content: registryContent, type: "created" });
|
|
6845
|
+
}
|
|
6846
|
+
const routerContent = generateRouterConfig(navRoutes, includeGuards);
|
|
6847
|
+
const routerFile = path15.join(routesPath, "index.tsx");
|
|
6848
|
+
if (!dryRun) {
|
|
6849
|
+
await ensureDirectory(routesPath);
|
|
6850
|
+
await writeText(routerFile, routerContent);
|
|
6851
|
+
}
|
|
6852
|
+
result.files.push({ path: routerFile, content: routerContent, type: "created" });
|
|
6853
|
+
if (includeLayouts) {
|
|
6854
|
+
const layoutsPath = path15.join(webPath, "src", "layouts");
|
|
6855
|
+
const contexts = [...new Set(navRoutes.map((r) => r.navRoute.split(".")[0]))];
|
|
6856
|
+
for (const context of contexts) {
|
|
6857
|
+
const layoutContent = generateLayout(context);
|
|
6858
|
+
const layoutFile = path15.join(layoutsPath, `${capitalize(context)}Layout.tsx`);
|
|
6859
|
+
if (!dryRun) {
|
|
6860
|
+
await ensureDirectory(layoutsPath);
|
|
6861
|
+
await writeText(layoutFile, layoutContent);
|
|
6862
|
+
}
|
|
6863
|
+
result.files.push({ path: layoutFile, content: layoutContent, type: "created" });
|
|
6864
|
+
}
|
|
6865
|
+
}
|
|
6866
|
+
if (includeGuards) {
|
|
6867
|
+
const guardsContent = generateRouteGuards();
|
|
6868
|
+
const guardsFile = path15.join(routesPath, "guards.tsx");
|
|
6869
|
+
if (!dryRun) {
|
|
6870
|
+
await writeText(guardsFile, guardsContent);
|
|
6871
|
+
}
|
|
6872
|
+
result.files.push({ path: guardsFile, content: guardsContent, type: "created" });
|
|
6873
|
+
}
|
|
6874
|
+
result.instructions.push(`Generated ${navRoutes.length} routes from ${source}`);
|
|
6875
|
+
result.instructions.push('Import routes: import { router } from "./routes";');
|
|
6876
|
+
result.instructions.push("Use with RouterProvider: <RouterProvider router={router} />");
|
|
6877
|
+
return result;
|
|
6878
|
+
}
|
|
6879
|
+
async function discoverNavRoutes(structure, scope) {
|
|
6880
|
+
const routes = [];
|
|
6881
|
+
const apiPath = structure.api;
|
|
6882
|
+
if (!apiPath) {
|
|
6883
|
+
logger.warn("No API project found");
|
|
6884
|
+
return routes;
|
|
6885
|
+
}
|
|
6886
|
+
const controllerFiles = await glob2("**/*Controller.cs", {
|
|
6887
|
+
cwd: apiPath,
|
|
6888
|
+
absolute: true,
|
|
6889
|
+
ignore: ["**/obj/**", "**/bin/**"]
|
|
6890
|
+
});
|
|
6891
|
+
for (const file of controllerFiles) {
|
|
6892
|
+
try {
|
|
6893
|
+
const content = await readText(file);
|
|
6894
|
+
const navRouteMatch = content.match(/\[NavRoute\s*\(\s*"([^"]+)"(?:\s*,\s*Suffix\s*=\s*"([^"]+)")?\s*\)\]/);
|
|
6895
|
+
if (navRouteMatch) {
|
|
6896
|
+
const navRoute = navRouteMatch[1];
|
|
6897
|
+
const suffix = navRouteMatch[2];
|
|
6898
|
+
const context = navRoute.split(".")[0];
|
|
6899
|
+
if (scope !== "all" && context !== scope) {
|
|
6900
|
+
continue;
|
|
6901
|
+
}
|
|
6902
|
+
const controllerMatch = path15.basename(file).match(/(.+)Controller\.cs$/);
|
|
6903
|
+
const controllerName = controllerMatch ? controllerMatch[1] : "Unknown";
|
|
6904
|
+
const methods = [];
|
|
6905
|
+
if (content.includes("[HttpGet]")) methods.push("GET");
|
|
6906
|
+
if (content.includes("[HttpPost]")) methods.push("POST");
|
|
6907
|
+
if (content.includes("[HttpPut]")) methods.push("PUT");
|
|
6908
|
+
if (content.includes("[HttpDelete]")) methods.push("DELETE");
|
|
6909
|
+
if (content.includes("[HttpPatch]")) methods.push("PATCH");
|
|
6910
|
+
const permissions = [];
|
|
6911
|
+
const authorizeMatches = content.matchAll(/\[Authorize\s*\(\s*[^)]*Policy\s*=\s*"([^"]+)"/g);
|
|
6912
|
+
for (const match of authorizeMatches) {
|
|
6913
|
+
permissions.push(match[1]);
|
|
6914
|
+
}
|
|
6915
|
+
const fullNavRoute = suffix ? `${navRoute}.${suffix}` : navRoute;
|
|
6916
|
+
routes.push({
|
|
6917
|
+
navRoute: fullNavRoute,
|
|
6918
|
+
apiPath: `/api/${navRoute.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`,
|
|
6919
|
+
webPath: `/${navRoute.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`,
|
|
6920
|
+
permissions,
|
|
6921
|
+
controller: controllerName,
|
|
6922
|
+
methods
|
|
6923
|
+
});
|
|
6924
|
+
}
|
|
6925
|
+
} catch {
|
|
6926
|
+
logger.debug(`Failed to parse controller: ${file}`);
|
|
6927
|
+
}
|
|
6928
|
+
}
|
|
6929
|
+
return routes.sort((a, b) => a.navRoute.localeCompare(b.navRoute));
|
|
6930
|
+
}
|
|
6931
|
+
function generateNavRouteRegistry(routes) {
|
|
6932
|
+
const lines = [
|
|
6933
|
+
"/**",
|
|
6934
|
+
" * NavRoute Registry",
|
|
6935
|
+
" *",
|
|
6936
|
+
" * Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY",
|
|
6937
|
+
" * Run `scaffold_routes` to regenerate",
|
|
6938
|
+
" */",
|
|
6939
|
+
"",
|
|
6940
|
+
"export interface NavRoute {",
|
|
6941
|
+
" navRoute: string;",
|
|
6942
|
+
" api: string;",
|
|
6943
|
+
" web: string;",
|
|
6944
|
+
" permissions: string[];",
|
|
6945
|
+
" controller?: string;",
|
|
6946
|
+
" methods: string[];",
|
|
6947
|
+
"}",
|
|
6948
|
+
"",
|
|
6949
|
+
"export const ROUTES: Record<string, NavRoute> = {"
|
|
6950
|
+
];
|
|
6951
|
+
for (const route of routes) {
|
|
6952
|
+
lines.push(` '${route.navRoute}': {`);
|
|
6953
|
+
lines.push(` navRoute: '${route.navRoute}',`);
|
|
6954
|
+
lines.push(` api: '${route.apiPath}',`);
|
|
6955
|
+
lines.push(` web: '${route.webPath}',`);
|
|
6956
|
+
lines.push(` permissions: [${route.permissions.map((p) => `'${p}'`).join(", ")}],`);
|
|
6957
|
+
if (route.controller) {
|
|
6958
|
+
lines.push(` controller: '${route.controller}',`);
|
|
6959
|
+
}
|
|
6960
|
+
lines.push(` methods: [${route.methods.map((m) => `'${m}'`).join(", ")}],`);
|
|
6961
|
+
lines.push(" },");
|
|
6962
|
+
}
|
|
6963
|
+
lines.push("};");
|
|
6964
|
+
lines.push("");
|
|
6965
|
+
lines.push("/**");
|
|
6966
|
+
lines.push(" * Get route configuration by NavRoute path");
|
|
6967
|
+
lines.push(" */");
|
|
6968
|
+
lines.push("export function getRoute(navRoute: string): NavRoute {");
|
|
6969
|
+
lines.push(" const route = ROUTES[navRoute];");
|
|
6970
|
+
lines.push(" if (!route) {");
|
|
6971
|
+
lines.push(" throw new Error(`Route not found: ${navRoute}`);");
|
|
6972
|
+
lines.push(" }");
|
|
6973
|
+
lines.push(" return route;");
|
|
6974
|
+
lines.push("}");
|
|
6975
|
+
lines.push("");
|
|
6976
|
+
lines.push("/**");
|
|
6977
|
+
lines.push(" * Check if user has permission for route");
|
|
6978
|
+
lines.push(" */");
|
|
6979
|
+
lines.push("export function hasRoutePermission(navRoute: string, userPermissions: string[]): boolean {");
|
|
6980
|
+
lines.push(" const route = ROUTES[navRoute];");
|
|
6981
|
+
lines.push(" if (!route || route.permissions.length === 0) return true;");
|
|
6982
|
+
lines.push(" return route.permissions.some(p => userPermissions.includes(p));");
|
|
6983
|
+
lines.push("}");
|
|
6984
|
+
lines.push("");
|
|
6985
|
+
lines.push("/**");
|
|
6986
|
+
lines.push(" * Get all routes for a context");
|
|
6987
|
+
lines.push(" */");
|
|
6988
|
+
lines.push("export function getRoutesByContext(context: string): NavRoute[] {");
|
|
6989
|
+
lines.push(" return Object.values(ROUTES).filter(r => r.navRoute.startsWith(`${context}.`));");
|
|
6990
|
+
lines.push("}");
|
|
6991
|
+
lines.push("");
|
|
6992
|
+
return lines.join("\n");
|
|
6993
|
+
}
|
|
6994
|
+
function generateRouterConfig(routes, includeGuards) {
|
|
6995
|
+
const routeTree = buildRouteTree(routes);
|
|
6996
|
+
const lines = [
|
|
6997
|
+
"/**",
|
|
6998
|
+
" * React Router Configuration",
|
|
6999
|
+
" *",
|
|
7000
|
+
" * Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY",
|
|
7001
|
+
" */",
|
|
7002
|
+
"",
|
|
7003
|
+
"import { createBrowserRouter, RouteObject } from 'react-router-dom';",
|
|
7004
|
+
"import { ROUTES } from './navRoutes.generated';"
|
|
7005
|
+
];
|
|
7006
|
+
if (includeGuards) {
|
|
7007
|
+
lines.push("import { ProtectedRoute, PermissionGuard } from './guards';");
|
|
7008
|
+
}
|
|
7009
|
+
const contexts = Object.keys(routeTree);
|
|
7010
|
+
for (const context of contexts) {
|
|
7011
|
+
lines.push(`import { ${capitalize(context)}Layout } from '../layouts/${capitalize(context)}Layout';`);
|
|
7012
|
+
}
|
|
7013
|
+
lines.push("");
|
|
7014
|
+
lines.push("// Page imports - customize these paths");
|
|
7015
|
+
for (const route of routes) {
|
|
7016
|
+
const pageName = route.navRoute.split(".").map(capitalize).join("");
|
|
7017
|
+
lines.push(`// import { ${pageName}Page } from '../pages/${pageName}Page';`);
|
|
7018
|
+
}
|
|
7019
|
+
lines.push("");
|
|
7020
|
+
lines.push("const routes: RouteObject[] = [");
|
|
7021
|
+
for (const [context, applications] of Object.entries(routeTree)) {
|
|
7022
|
+
lines.push(" {");
|
|
7023
|
+
lines.push(` path: '${context}',`);
|
|
7024
|
+
lines.push(` element: <${capitalize(context)}Layout />,`);
|
|
7025
|
+
lines.push(" children: [");
|
|
7026
|
+
for (const [app, modules] of Object.entries(applications)) {
|
|
7027
|
+
lines.push(" {");
|
|
7028
|
+
lines.push(` path: '${app}',`);
|
|
7029
|
+
lines.push(" children: [");
|
|
7030
|
+
for (const route of modules) {
|
|
7031
|
+
const modulePath = route.navRoute.split(".").slice(2).join("/");
|
|
7032
|
+
const pageName = route.navRoute.split(".").map(capitalize).join("");
|
|
7033
|
+
if (includeGuards && route.permissions.length > 0) {
|
|
7034
|
+
lines.push(" {");
|
|
7035
|
+
lines.push(` path: '${modulePath || ""}',`);
|
|
7036
|
+
lines.push(` element: (`);
|
|
7037
|
+
lines.push(` <PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}>`);
|
|
7038
|
+
lines.push(` {/* <${pageName}Page /> */}`);
|
|
7039
|
+
lines.push(` <div>TODO: ${pageName}Page</div>`);
|
|
7040
|
+
lines.push(` </PermissionGuard>`);
|
|
7041
|
+
lines.push(` ),`);
|
|
7042
|
+
lines.push(" },");
|
|
7043
|
+
} else {
|
|
7044
|
+
lines.push(" {");
|
|
7045
|
+
lines.push(` path: '${modulePath || ""}',`);
|
|
7046
|
+
lines.push(` element: <div>TODO: ${pageName}Page</div>,`);
|
|
7047
|
+
lines.push(" },");
|
|
7048
|
+
}
|
|
7049
|
+
}
|
|
7050
|
+
lines.push(" ],");
|
|
7051
|
+
lines.push(" },");
|
|
7052
|
+
}
|
|
7053
|
+
lines.push(" ],");
|
|
7054
|
+
lines.push(" },");
|
|
7055
|
+
}
|
|
7056
|
+
lines.push("];");
|
|
7057
|
+
lines.push("");
|
|
7058
|
+
lines.push("export const router = createBrowserRouter(routes);");
|
|
7059
|
+
lines.push("");
|
|
7060
|
+
lines.push("export default router;");
|
|
7061
|
+
lines.push("");
|
|
7062
|
+
return lines.join("\n");
|
|
7063
|
+
}
|
|
7064
|
+
function buildRouteTree(routes) {
|
|
7065
|
+
const tree = {};
|
|
7066
|
+
for (const route of routes) {
|
|
7067
|
+
const parts = route.navRoute.split(".");
|
|
7068
|
+
const context = parts[0];
|
|
7069
|
+
const app = parts[1] || "default";
|
|
7070
|
+
if (!tree[context]) {
|
|
7071
|
+
tree[context] = {};
|
|
7072
|
+
}
|
|
7073
|
+
if (!tree[context][app]) {
|
|
7074
|
+
tree[context][app] = [];
|
|
7075
|
+
}
|
|
7076
|
+
tree[context][app].push(route);
|
|
7077
|
+
}
|
|
7078
|
+
return tree;
|
|
7079
|
+
}
|
|
7080
|
+
function generateLayout(context) {
|
|
7081
|
+
const contextCapitalized = capitalize(context);
|
|
7082
|
+
return `/**
|
|
7083
|
+
* ${contextCapitalized} Layout
|
|
7084
|
+
*
|
|
7085
|
+
* Auto-generated by SmartStack MCP - Customize as needed
|
|
7086
|
+
*/
|
|
7087
|
+
|
|
7088
|
+
import React from 'react';
|
|
7089
|
+
import { Outlet, Link, useLocation } from 'react-router-dom';
|
|
7090
|
+
import { ROUTES, getRoutesByContext } from '../routes/navRoutes.generated';
|
|
7091
|
+
|
|
7092
|
+
export const ${contextCapitalized}Layout: React.FC = () => {
|
|
7093
|
+
const location = useLocation();
|
|
7094
|
+
const contextRoutes = getRoutesByContext('${context}');
|
|
7095
|
+
|
|
7096
|
+
return (
|
|
7097
|
+
<div className="flex h-screen bg-gray-100">
|
|
7098
|
+
{/* Sidebar */}
|
|
7099
|
+
<aside className="w-64 bg-white shadow-sm">
|
|
7100
|
+
<div className="p-4 border-b">
|
|
7101
|
+
<h1 className="text-xl font-semibold text-gray-900">${contextCapitalized}</h1>
|
|
7102
|
+
</div>
|
|
7103
|
+
<nav className="p-4">
|
|
7104
|
+
<ul className="space-y-2">
|
|
7105
|
+
{contextRoutes.map((route) => (
|
|
7106
|
+
<li key={route.navRoute}>
|
|
7107
|
+
<Link
|
|
7108
|
+
to={route.web}
|
|
7109
|
+
className={\`block px-3 py-2 rounded-md \${
|
|
7110
|
+
location.pathname === route.web
|
|
7111
|
+
? 'bg-blue-50 text-blue-700'
|
|
7112
|
+
: 'text-gray-700 hover:bg-gray-50'
|
|
7113
|
+
}\`}
|
|
7114
|
+
>
|
|
7115
|
+
{route.navRoute.split('.').pop()}
|
|
7116
|
+
</Link>
|
|
7117
|
+
</li>
|
|
7118
|
+
))}
|
|
7119
|
+
</ul>
|
|
7120
|
+
</nav>
|
|
7121
|
+
</aside>
|
|
7122
|
+
|
|
7123
|
+
{/* Main content */}
|
|
7124
|
+
<main className="flex-1 overflow-auto">
|
|
7125
|
+
<div className="p-6">
|
|
7126
|
+
<Outlet />
|
|
7127
|
+
</div>
|
|
7128
|
+
</main>
|
|
7129
|
+
</div>
|
|
7130
|
+
);
|
|
7131
|
+
};
|
|
7132
|
+
|
|
7133
|
+
export default ${contextCapitalized}Layout;
|
|
7134
|
+
`;
|
|
7135
|
+
}
|
|
7136
|
+
function generateRouteGuards() {
|
|
7137
|
+
return `/**
|
|
7138
|
+
* Route Guards
|
|
7139
|
+
*
|
|
7140
|
+
* Auto-generated by SmartStack MCP - Customize as needed
|
|
7141
|
+
*/
|
|
7142
|
+
|
|
7143
|
+
import React from 'react';
|
|
7144
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
7145
|
+
|
|
7146
|
+
interface ProtectedRouteProps {
|
|
7147
|
+
children: React.ReactNode;
|
|
7148
|
+
redirectTo?: string;
|
|
7149
|
+
}
|
|
7150
|
+
|
|
7151
|
+
interface PermissionGuardProps {
|
|
7152
|
+
children: React.ReactNode;
|
|
7153
|
+
permissions: string[];
|
|
7154
|
+
fallback?: React.ReactNode;
|
|
7155
|
+
}
|
|
7156
|
+
|
|
7157
|
+
/**
|
|
7158
|
+
* Protect routes that require authentication
|
|
7159
|
+
*/
|
|
7160
|
+
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
|
7161
|
+
children,
|
|
7162
|
+
redirectTo = '/login'
|
|
7163
|
+
}) => {
|
|
7164
|
+
const location = useLocation();
|
|
7165
|
+
|
|
7166
|
+
// TODO: Replace with your auth hook
|
|
7167
|
+
const isAuthenticated = true; // useAuth().isAuthenticated;
|
|
7168
|
+
|
|
7169
|
+
if (!isAuthenticated) {
|
|
7170
|
+
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
|
7171
|
+
}
|
|
7172
|
+
|
|
7173
|
+
return <>{children}</>;
|
|
7174
|
+
};
|
|
7175
|
+
|
|
7176
|
+
/**
|
|
7177
|
+
* Guard routes based on user permissions
|
|
7178
|
+
*/
|
|
7179
|
+
export const PermissionGuard: React.FC<PermissionGuardProps> = ({
|
|
7180
|
+
children,
|
|
7181
|
+
permissions,
|
|
7182
|
+
fallback
|
|
7183
|
+
}) => {
|
|
7184
|
+
// TODO: Replace with your auth hook
|
|
7185
|
+
const userPermissions: string[] = []; // useAuth().permissions;
|
|
7186
|
+
|
|
7187
|
+
const hasPermission = permissions.length === 0 ||
|
|
7188
|
+
permissions.some(p => userPermissions.includes(p));
|
|
7189
|
+
|
|
7190
|
+
if (!hasPermission) {
|
|
7191
|
+
if (fallback) return <>{fallback}</>;
|
|
7192
|
+
return (
|
|
7193
|
+
<div className="flex items-center justify-center h-64">
|
|
7194
|
+
<div className="text-center">
|
|
7195
|
+
<h2 className="text-xl font-semibold text-gray-900">Access Denied</h2>
|
|
7196
|
+
<p className="mt-2 text-gray-600">
|
|
7197
|
+
You don't have permission to access this page.
|
|
7198
|
+
</p>
|
|
7199
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
7200
|
+
Required: {permissions.join(', ')}
|
|
7201
|
+
</p>
|
|
7202
|
+
</div>
|
|
7203
|
+
</div>
|
|
7204
|
+
);
|
|
7205
|
+
}
|
|
7206
|
+
|
|
7207
|
+
return <>{children}</>;
|
|
7208
|
+
};
|
|
7209
|
+
`;
|
|
7210
|
+
}
|
|
7211
|
+
function capitalize(str) {
|
|
7212
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
7213
|
+
}
|
|
7214
|
+
function formatResult5(result, input) {
|
|
7215
|
+
const lines = [];
|
|
7216
|
+
lines.push("# Scaffold Routes");
|
|
7217
|
+
lines.push("");
|
|
7218
|
+
if (input.options?.dryRun) {
|
|
7219
|
+
lines.push("> **DRY RUN** - No files were written");
|
|
7220
|
+
lines.push("");
|
|
7221
|
+
}
|
|
7222
|
+
lines.push("## Configuration");
|
|
7223
|
+
lines.push("");
|
|
7224
|
+
lines.push(`- **Source**: ${input.source}`);
|
|
7225
|
+
lines.push(`- **Scope**: ${input.scope}`);
|
|
7226
|
+
lines.push("");
|
|
7227
|
+
lines.push("## Generated Files");
|
|
7228
|
+
lines.push("");
|
|
7229
|
+
for (const file of result.files) {
|
|
7230
|
+
const relativePath = file.path.replace(/\\/g, "/").split("/src/").pop() || file.path;
|
|
7231
|
+
lines.push(`### ${relativePath}`);
|
|
7232
|
+
lines.push("");
|
|
7233
|
+
lines.push("```tsx");
|
|
7234
|
+
lines.push(file.content.substring(0, 2e3) + (file.content.length > 2e3 ? "\n// ... (truncated)" : ""));
|
|
7235
|
+
lines.push("```");
|
|
7236
|
+
lines.push("");
|
|
7237
|
+
}
|
|
7238
|
+
lines.push("## Instructions");
|
|
7239
|
+
lines.push("");
|
|
7240
|
+
for (const instruction of result.instructions) {
|
|
7241
|
+
lines.push(`- ${instruction}`);
|
|
7242
|
+
}
|
|
7243
|
+
return lines.join("\n");
|
|
7244
|
+
}
|
|
7245
|
+
|
|
7246
|
+
// src/tools/validate-frontend-routes.ts
|
|
7247
|
+
import path16 from "path";
|
|
7248
|
+
import { glob as glob3 } from "glob";
|
|
7249
|
+
var validateFrontendRoutesTool = {
|
|
7250
|
+
name: "validate_frontend_routes",
|
|
7251
|
+
description: `Validate frontend routes against backend NavRoute attributes.
|
|
7252
|
+
|
|
7253
|
+
Checks:
|
|
7254
|
+
- navRoutes.generated.ts exists and is up-to-date
|
|
7255
|
+
- API clients use correct NavRoute paths
|
|
7256
|
+
- React Router configuration matches backend routes
|
|
7257
|
+
- Permission configurations are synchronized
|
|
7258
|
+
|
|
7259
|
+
Example:
|
|
7260
|
+
validate_frontend_routes scope="all"
|
|
7261
|
+
|
|
7262
|
+
Reports issues and provides actionable recommendations for synchronization.`,
|
|
7263
|
+
inputSchema: {
|
|
7264
|
+
type: "object",
|
|
7265
|
+
properties: {
|
|
7266
|
+
scope: {
|
|
7267
|
+
type: "string",
|
|
7268
|
+
enum: ["api-clients", "routes", "registry", "all"],
|
|
7269
|
+
default: "all",
|
|
7270
|
+
description: "Scope of validation"
|
|
7271
|
+
},
|
|
7272
|
+
options: {
|
|
7273
|
+
type: "object",
|
|
7274
|
+
properties: {
|
|
7275
|
+
fix: { type: "boolean", default: false, description: "Auto-fix minor issues" },
|
|
7276
|
+
strict: { type: "boolean", default: false, description: "Fail on warnings" }
|
|
7277
|
+
}
|
|
7278
|
+
}
|
|
7279
|
+
}
|
|
7280
|
+
}
|
|
7281
|
+
};
|
|
7282
|
+
async function handleValidateFrontendRoutes(args, config) {
|
|
7283
|
+
const input = ValidateFrontendRoutesInputSchema.parse(args);
|
|
7284
|
+
logger.info("Validating frontend routes", { scope: input.scope });
|
|
7285
|
+
const result = await validateFrontendRoutes(input, config);
|
|
7286
|
+
return formatResult6(result, input);
|
|
7287
|
+
}
|
|
7288
|
+
async function validateFrontendRoutes(input, config) {
|
|
7289
|
+
const result = {
|
|
7290
|
+
valid: true,
|
|
7291
|
+
registry: {
|
|
7292
|
+
exists: false,
|
|
7293
|
+
routeCount: 0,
|
|
7294
|
+
outdated: []
|
|
7295
|
+
},
|
|
7296
|
+
apiClients: {
|
|
7297
|
+
total: 0,
|
|
7298
|
+
valid: 0,
|
|
7299
|
+
issues: []
|
|
7300
|
+
},
|
|
7301
|
+
routes: {
|
|
7302
|
+
total: 0,
|
|
7303
|
+
orphaned: [],
|
|
7304
|
+
missing: []
|
|
7305
|
+
},
|
|
7306
|
+
recommendations: []
|
|
7307
|
+
};
|
|
7308
|
+
const { scope } = input;
|
|
7309
|
+
const projectRoot = config.smartstack.projectPath;
|
|
7310
|
+
const structure = await findSmartStackStructure(projectRoot);
|
|
7311
|
+
const webPath = structure.web || path16.join(projectRoot, "web", "smartstack-web");
|
|
7312
|
+
const backendRoutes = await discoverBackendNavRoutes(structure);
|
|
7313
|
+
if (scope === "all" || scope === "registry") {
|
|
7314
|
+
await validateRegistry(webPath, backendRoutes, result);
|
|
7315
|
+
}
|
|
7316
|
+
if (scope === "all" || scope === "api-clients") {
|
|
7317
|
+
await validateApiClients(webPath, backendRoutes, result);
|
|
7318
|
+
}
|
|
7319
|
+
if (scope === "all" || scope === "routes") {
|
|
7320
|
+
await validateRoutes(webPath, backendRoutes, result);
|
|
7321
|
+
}
|
|
7322
|
+
generateRecommendations2(result);
|
|
7323
|
+
result.valid = result.apiClients.issues.filter((i) => i.severity === "error").length === 0 && result.routes.missing.length === 0 && result.registry.exists;
|
|
7324
|
+
return result;
|
|
7325
|
+
}
|
|
7326
|
+
async function discoverBackendNavRoutes(structure) {
|
|
7327
|
+
const routes = [];
|
|
7328
|
+
const apiPath = structure.api;
|
|
7329
|
+
if (!apiPath) {
|
|
7330
|
+
return routes;
|
|
7331
|
+
}
|
|
7332
|
+
const controllerFiles = await glob3("**/*Controller.cs", {
|
|
7333
|
+
cwd: apiPath,
|
|
7334
|
+
absolute: true,
|
|
7335
|
+
ignore: ["**/obj/**", "**/bin/**"]
|
|
7336
|
+
});
|
|
7337
|
+
for (const file of controllerFiles) {
|
|
7338
|
+
try {
|
|
7339
|
+
const content = await readText(file);
|
|
7340
|
+
const navRouteMatch = content.match(/\[NavRoute\s*\(\s*"([^"]+)"(?:\s*,\s*Suffix\s*=\s*"([^"]+)")?\s*\)\]/);
|
|
7341
|
+
if (navRouteMatch) {
|
|
7342
|
+
const navRoute = navRouteMatch[1];
|
|
7343
|
+
const suffix = navRouteMatch[2];
|
|
7344
|
+
const fullNavRoute = suffix ? `${navRoute}.${suffix}` : navRoute;
|
|
7345
|
+
const controllerMatch = path16.basename(file).match(/(.+)Controller\.cs$/);
|
|
7346
|
+
const controllerName = controllerMatch ? controllerMatch[1] : "Unknown";
|
|
7347
|
+
const methods = [];
|
|
7348
|
+
if (content.includes("[HttpGet]")) methods.push("GET");
|
|
7349
|
+
if (content.includes("[HttpPost]")) methods.push("POST");
|
|
7350
|
+
if (content.includes("[HttpPut]")) methods.push("PUT");
|
|
7351
|
+
if (content.includes("[HttpDelete]")) methods.push("DELETE");
|
|
7352
|
+
const permissions = [];
|
|
7353
|
+
const authorizeMatches = content.matchAll(/\[Authorize\s*\(\s*[^)]*Policy\s*=\s*"([^"]+)"/g);
|
|
7354
|
+
for (const match of authorizeMatches) {
|
|
7355
|
+
permissions.push(match[1]);
|
|
7356
|
+
}
|
|
7357
|
+
routes.push({
|
|
7358
|
+
navRoute: fullNavRoute,
|
|
7359
|
+
apiPath: `/api/${navRoute.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`,
|
|
7360
|
+
webPath: `/${navRoute.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`,
|
|
7361
|
+
permissions,
|
|
7362
|
+
controller: controllerName,
|
|
7363
|
+
methods
|
|
7364
|
+
});
|
|
7365
|
+
}
|
|
7366
|
+
} catch {
|
|
7367
|
+
logger.debug(`Failed to parse controller: ${file}`);
|
|
7368
|
+
}
|
|
7369
|
+
}
|
|
7370
|
+
return routes;
|
|
7371
|
+
}
|
|
7372
|
+
async function validateRegistry(webPath, backendRoutes, result) {
|
|
7373
|
+
const registryPath = path16.join(webPath, "src", "routes", "navRoutes.generated.ts");
|
|
7374
|
+
if (!await fileExists(registryPath)) {
|
|
7375
|
+
result.registry.exists = false;
|
|
7376
|
+
result.recommendations.push("Run `scaffold_routes` to generate navRoutes.generated.ts");
|
|
7377
|
+
return;
|
|
7378
|
+
}
|
|
7379
|
+
result.registry.exists = true;
|
|
7380
|
+
try {
|
|
7381
|
+
const content = await readText(registryPath);
|
|
7382
|
+
const routeMatches = content.matchAll(/'([a-z.]+)':\s*\{/g);
|
|
7383
|
+
const registryRoutes = /* @__PURE__ */ new Set();
|
|
7384
|
+
for (const match of routeMatches) {
|
|
7385
|
+
registryRoutes.add(match[1]);
|
|
7386
|
+
}
|
|
7387
|
+
result.registry.routeCount = registryRoutes.size;
|
|
7388
|
+
for (const backendRoute of backendRoutes) {
|
|
7389
|
+
if (!registryRoutes.has(backendRoute.navRoute)) {
|
|
7390
|
+
result.registry.outdated.push(backendRoute.navRoute);
|
|
7391
|
+
}
|
|
7392
|
+
}
|
|
7393
|
+
for (const registryRoute of registryRoutes) {
|
|
7394
|
+
if (!backendRoutes.find((r) => r.navRoute === registryRoute)) {
|
|
7395
|
+
result.registry.outdated.push(`${registryRoute} (removed from backend)`);
|
|
7396
|
+
}
|
|
7397
|
+
}
|
|
7398
|
+
} catch {
|
|
7399
|
+
result.registry.exists = false;
|
|
7400
|
+
}
|
|
7401
|
+
}
|
|
7402
|
+
async function validateApiClients(webPath, backendRoutes, result) {
|
|
7403
|
+
const servicesPath = path16.join(webPath, "src", "services", "api");
|
|
7404
|
+
const clientFiles = await glob3("**/*.ts", {
|
|
7405
|
+
cwd: servicesPath,
|
|
7406
|
+
absolute: true,
|
|
7407
|
+
ignore: ["**/index.ts"]
|
|
7408
|
+
});
|
|
7409
|
+
result.apiClients.total = clientFiles.length;
|
|
7410
|
+
for (const file of clientFiles) {
|
|
7411
|
+
try {
|
|
7412
|
+
const content = await readText(file);
|
|
7413
|
+
const relativePath = path16.relative(webPath, file);
|
|
7414
|
+
const usesRegistry = content.includes("getRoute('") || content.includes('getRoute("');
|
|
7415
|
+
if (!usesRegistry) {
|
|
7416
|
+
const hardcodedMatch = content.match(/apiClient\.(get|post|put|delete)\s*[<(]\s*['"`]([^'"`]+)['"`]/);
|
|
7417
|
+
if (hardcodedMatch) {
|
|
7418
|
+
result.apiClients.issues.push({
|
|
7419
|
+
type: "invalid-path",
|
|
7420
|
+
severity: "warning",
|
|
7421
|
+
file: relativePath,
|
|
7422
|
+
message: `Hardcoded API path: ${hardcodedMatch[2]}`,
|
|
7423
|
+
suggestion: "Use getRoute() from navRoutes.generated.ts instead"
|
|
7424
|
+
});
|
|
7425
|
+
}
|
|
7426
|
+
} else {
|
|
7427
|
+
const navRouteMatch = content.match(/getRoute\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
|
|
7428
|
+
if (navRouteMatch) {
|
|
7429
|
+
const navRoute = navRouteMatch[1];
|
|
7430
|
+
const backendRoute = backendRoutes.find((r) => r.navRoute === navRoute);
|
|
7431
|
+
if (!backendRoute) {
|
|
7432
|
+
result.apiClients.issues.push({
|
|
7433
|
+
type: "missing-route",
|
|
7434
|
+
severity: "error",
|
|
7435
|
+
file: relativePath,
|
|
7436
|
+
navRoute,
|
|
7437
|
+
message: `NavRoute "${navRoute}" not found in backend controllers`,
|
|
7438
|
+
suggestion: "Verify the NavRoute path or update the backend controller"
|
|
7439
|
+
});
|
|
7440
|
+
} else {
|
|
7441
|
+
result.apiClients.valid++;
|
|
7442
|
+
}
|
|
7443
|
+
}
|
|
7444
|
+
}
|
|
7445
|
+
} catch {
|
|
7446
|
+
logger.debug(`Failed to parse API client: ${file}`);
|
|
7447
|
+
}
|
|
7448
|
+
}
|
|
7449
|
+
}
|
|
7450
|
+
async function validateRoutes(webPath, backendRoutes, result) {
|
|
7451
|
+
const routesPath = path16.join(webPath, "src", "routes", "index.tsx");
|
|
7452
|
+
if (!await fileExists(routesPath)) {
|
|
7453
|
+
result.routes.total = 0;
|
|
7454
|
+
result.routes.missing = backendRoutes.map((r) => r.navRoute);
|
|
7455
|
+
return;
|
|
7456
|
+
}
|
|
7457
|
+
try {
|
|
7458
|
+
const content = await readText(routesPath);
|
|
7459
|
+
const pathMatches = content.matchAll(/path:\s*['"`]([^'"`]+)['"`]/g);
|
|
7460
|
+
const frontendPaths = /* @__PURE__ */ new Set();
|
|
7461
|
+
for (const match of pathMatches) {
|
|
7462
|
+
frontendPaths.add(match[1]);
|
|
7463
|
+
}
|
|
7464
|
+
result.routes.total = frontendPaths.size;
|
|
7465
|
+
for (const backendRoute of backendRoutes) {
|
|
7466
|
+
const webPath2 = backendRoute.webPath.replace(/^\//, "");
|
|
7467
|
+
const parts = webPath2.split("/");
|
|
7468
|
+
let found = false;
|
|
7469
|
+
for (const pathPart of parts) {
|
|
7470
|
+
if (frontendPaths.has(pathPart)) {
|
|
7471
|
+
found = true;
|
|
7472
|
+
break;
|
|
7473
|
+
}
|
|
7474
|
+
}
|
|
7475
|
+
if (!found && parts.length > 0) {
|
|
7476
|
+
result.routes.missing.push(backendRoute.navRoute);
|
|
7477
|
+
}
|
|
7478
|
+
}
|
|
7479
|
+
for (const frontendPath of frontendPaths) {
|
|
7480
|
+
if (frontendPath === "*" || frontendPath === "" || frontendPath.startsWith(":")) {
|
|
7481
|
+
continue;
|
|
7482
|
+
}
|
|
7483
|
+
const matchingBackend = backendRoutes.find(
|
|
7484
|
+
(r) => r.webPath.includes(frontendPath) || r.navRoute.includes(frontendPath)
|
|
7485
|
+
);
|
|
7486
|
+
if (!matchingBackend) {
|
|
7487
|
+
result.routes.orphaned.push(frontendPath);
|
|
7488
|
+
}
|
|
7489
|
+
}
|
|
7490
|
+
} catch {
|
|
7491
|
+
result.routes.total = 0;
|
|
7492
|
+
result.routes.missing = backendRoutes.map((r) => r.navRoute);
|
|
7493
|
+
}
|
|
7494
|
+
}
|
|
7495
|
+
function generateRecommendations2(result) {
|
|
7496
|
+
if (!result.registry.exists) {
|
|
7497
|
+
result.recommendations.push('Run `scaffold_routes source="controllers"` to generate route registry');
|
|
7498
|
+
} else if (result.registry.outdated.length > 0) {
|
|
7499
|
+
result.recommendations.push(`Registry is outdated: ${result.registry.outdated.length} routes need sync. Run \`scaffold_routes\``);
|
|
7500
|
+
}
|
|
7501
|
+
if (result.apiClients.issues.length > 0) {
|
|
7502
|
+
const hardcoded = result.apiClients.issues.filter((i) => i.type === "invalid-path").length;
|
|
7503
|
+
const missing = result.apiClients.issues.filter((i) => i.type === "missing-route").length;
|
|
7504
|
+
if (hardcoded > 0) {
|
|
7505
|
+
result.recommendations.push(`${hardcoded} API clients use hardcoded paths. Migrate to getRoute()`);
|
|
7506
|
+
}
|
|
7507
|
+
if (missing > 0) {
|
|
7508
|
+
result.recommendations.push(`${missing} API clients reference non-existent NavRoutes`);
|
|
7509
|
+
}
|
|
7510
|
+
}
|
|
7511
|
+
if (result.routes.missing.length > 0) {
|
|
7512
|
+
result.recommendations.push(`${result.routes.missing.length} backend routes have no frontend counterpart`);
|
|
7513
|
+
}
|
|
7514
|
+
if (result.routes.orphaned.length > 0) {
|
|
7515
|
+
result.recommendations.push(`${result.routes.orphaned.length} frontend routes have no backend NavRoute`);
|
|
7516
|
+
}
|
|
7517
|
+
if (result.valid && result.recommendations.length === 0) {
|
|
7518
|
+
result.recommendations.push("All routes are synchronized between frontend and backend");
|
|
7519
|
+
}
|
|
7520
|
+
}
|
|
7521
|
+
function formatResult6(result, _input) {
|
|
7522
|
+
const lines = [];
|
|
7523
|
+
const statusIcon = result.valid ? "\u2705" : "\u274C";
|
|
7524
|
+
lines.push(`# Frontend Route Validation ${statusIcon}`);
|
|
7525
|
+
lines.push("");
|
|
7526
|
+
lines.push("## Summary");
|
|
7527
|
+
lines.push("");
|
|
7528
|
+
lines.push(`| Metric | Value |`);
|
|
7529
|
+
lines.push(`|--------|-------|`);
|
|
7530
|
+
lines.push(`| Valid | ${result.valid ? "Yes" : "No"} |`);
|
|
7531
|
+
lines.push(`| Registry Exists | ${result.registry.exists ? "Yes" : "No"} |`);
|
|
7532
|
+
lines.push(`| Registry Routes | ${result.registry.routeCount} |`);
|
|
7533
|
+
lines.push(`| API Clients | ${result.apiClients.valid}/${result.apiClients.total} valid |`);
|
|
7534
|
+
lines.push(`| Frontend Routes | ${result.routes.total} |`);
|
|
7535
|
+
lines.push(`| Missing Routes | ${result.routes.missing.length} |`);
|
|
7536
|
+
lines.push(`| Orphaned Routes | ${result.routes.orphaned.length} |`);
|
|
7537
|
+
lines.push("");
|
|
7538
|
+
if (result.registry.outdated.length > 0) {
|
|
7539
|
+
lines.push("## Outdated Registry Entries");
|
|
7540
|
+
lines.push("");
|
|
7541
|
+
for (const route of result.registry.outdated) {
|
|
7542
|
+
lines.push(`- \`${route}\``);
|
|
7543
|
+
}
|
|
7544
|
+
lines.push("");
|
|
7545
|
+
}
|
|
7546
|
+
if (result.apiClients.issues.length > 0) {
|
|
7547
|
+
lines.push("## API Client Issues");
|
|
7548
|
+
lines.push("");
|
|
7549
|
+
for (const issue of result.apiClients.issues) {
|
|
7550
|
+
const icon = issue.severity === "error" ? "\u274C" : "\u26A0\uFE0F";
|
|
7551
|
+
lines.push(`### ${icon} ${issue.file}`);
|
|
7552
|
+
lines.push("");
|
|
7553
|
+
lines.push(`- **Type**: ${issue.type}`);
|
|
7554
|
+
lines.push(`- **Message**: ${issue.message}`);
|
|
7555
|
+
if (issue.navRoute) {
|
|
7556
|
+
lines.push(`- **NavRoute**: \`${issue.navRoute}\``);
|
|
7557
|
+
}
|
|
7558
|
+
lines.push(`- **Suggestion**: ${issue.suggestion}`);
|
|
7559
|
+
lines.push("");
|
|
7560
|
+
}
|
|
7561
|
+
}
|
|
7562
|
+
if (result.routes.missing.length > 0) {
|
|
7563
|
+
lines.push("## Missing Frontend Routes");
|
|
7564
|
+
lines.push("");
|
|
7565
|
+
lines.push("These backend NavRoutes have no corresponding frontend route:");
|
|
7566
|
+
lines.push("");
|
|
7567
|
+
for (const route of result.routes.missing) {
|
|
7568
|
+
lines.push(`- \`${route}\``);
|
|
7569
|
+
}
|
|
7570
|
+
lines.push("");
|
|
7571
|
+
}
|
|
7572
|
+
if (result.routes.orphaned.length > 0) {
|
|
7573
|
+
lines.push("## Orphaned Frontend Routes");
|
|
7574
|
+
lines.push("");
|
|
7575
|
+
lines.push("These frontend routes have no corresponding backend NavRoute:");
|
|
7576
|
+
lines.push("");
|
|
7577
|
+
for (const route of result.routes.orphaned) {
|
|
7578
|
+
lines.push(`- \`${route}\``);
|
|
7579
|
+
}
|
|
7580
|
+
lines.push("");
|
|
7581
|
+
}
|
|
7582
|
+
lines.push("## Recommendations");
|
|
7583
|
+
lines.push("");
|
|
7584
|
+
for (const rec of result.recommendations) {
|
|
7585
|
+
lines.push(`- ${rec}`);
|
|
7586
|
+
}
|
|
7587
|
+
lines.push("");
|
|
7588
|
+
lines.push("## Commands");
|
|
7589
|
+
lines.push("");
|
|
7590
|
+
lines.push("```bash");
|
|
7591
|
+
lines.push("# Regenerate route registry");
|
|
7592
|
+
lines.push('scaffold_routes source="controllers"');
|
|
7593
|
+
lines.push("");
|
|
7594
|
+
lines.push("# Generate API client for a specific NavRoute");
|
|
7595
|
+
lines.push('scaffold_api_client navRoute="platform.administration.users" name="User"');
|
|
7596
|
+
lines.push("```");
|
|
7597
|
+
return lines.join("\n");
|
|
7598
|
+
}
|
|
7599
|
+
|
|
7600
|
+
// src/resources/conventions.ts
|
|
7601
|
+
var conventionsResourceTemplate = {
|
|
7602
|
+
uri: "smartstack://conventions",
|
|
7603
|
+
name: "AtlasHub Conventions",
|
|
7604
|
+
description: "Documentation of AtlasHub/SmartStack naming conventions, patterns, and best practices",
|
|
7605
|
+
mimeType: "text/markdown"
|
|
7606
|
+
};
|
|
7607
|
+
async function getConventionsResource(config) {
|
|
7608
|
+
const { schemas, tablePrefixes, codePrefixes, migrationFormat, namespaces, servicePattern } = config.conventions;
|
|
7609
|
+
return `# AtlasHub SmartStack Conventions
|
|
7610
|
+
|
|
7611
|
+
## Overview
|
|
7612
|
+
|
|
7613
|
+
This document describes the mandatory conventions for extending the SmartStack/AtlasHub platform.
|
|
7614
|
+
Following these conventions ensures compatibility and prevents conflicts.
|
|
7615
|
+
|
|
7616
|
+
---
|
|
7617
|
+
|
|
7618
|
+
## 1. Database Conventions
|
|
7619
|
+
|
|
7620
|
+
### SQL Schemas
|
|
7621
|
+
|
|
7622
|
+
SmartStack uses SQL Server schemas to separate platform tables from client extensions:
|
|
7623
|
+
|
|
7624
|
+
| Schema | Usage | Description |
|
|
7625
|
+
|--------|-------|-------------|
|
|
7626
|
+
| \`${schemas.platform}\` | SmartStack platform | All native SmartStack tables |
|
|
7627
|
+
| \`${schemas.extensions}\` | Client extensions | Custom tables added by clients |
|
|
7628
|
+
|
|
7629
|
+
### Domain Table Prefixes
|
|
7630
|
+
|
|
7631
|
+
Tables are organized by domain using prefixes:
|
|
7632
|
+
|
|
7633
|
+
| Prefix | Domain | Example Tables |
|
|
7634
|
+
|--------|--------|----------------|
|
|
7635
|
+
| \`auth_\` | Authorization | auth_Users, auth_Roles, auth_Permissions |
|
|
7636
|
+
| \`nav_\` | Navigation | nav_Contexts, nav_Applications, nav_Modules |
|
|
7637
|
+
| \`usr_\` | User profiles | usr_Profiles, usr_Preferences |
|
|
7638
|
+
| \`ai_\` | AI features | ai_Providers, ai_Models, ai_Prompts |
|
|
7639
|
+
| \`cfg_\` | Configuration | cfg_Settings |
|
|
7640
|
+
| \`wkf_\` | Workflows | wkf_EmailTemplates, wkf_Workflows |
|
|
7641
|
+
| \`support_\` | Support | support_Tickets, support_Comments |
|
|
7642
|
+
| \`entra_\` | Entra sync | entra_Groups, entra_SyncState |
|
|
7643
|
+
| \`ref_\` | References | ref_Companies, ref_Departments |
|
|
7644
|
+
| \`loc_\` | Localization | loc_Languages, loc_Translations |
|
|
7645
|
+
| \`lic_\` | Licensing | lic_Licenses |
|
|
7646
|
+
| \`tenant_\` | Multi-Tenancy | tenant_Tenants, tenant_TenantUsers, tenant_TenantUserRoles |
|
|
7647
|
+
|
|
7648
|
+
### Navigation Code Prefixes
|
|
7649
|
+
|
|
7650
|
+
All navigation data (Context, Application, Module, Section, Resource) uses code prefixes to distinguish system data from client extensions:
|
|
7651
|
+
|
|
7652
|
+
| Origin | Prefix | Usage | Example |
|
|
7653
|
+
|--------|--------|-------|---------|
|
|
7654
|
+
| SmartStack (system) | \`${codePrefixes.core}\` | Protected, delivered with SmartStack | \`${codePrefixes.core}administration\` |
|
|
7655
|
+
| Client (extension) | \`${codePrefixes.extension}\` | Custom, added by clients | \`${codePrefixes.extension}it\` |
|
|
7656
|
+
|
|
7657
|
+
**Navigation Hierarchy (5 levels):**
|
|
7658
|
+
|
|
7659
|
+
\`\`\`
|
|
7660
|
+
Context \u2192 Application \u2192 Module \u2192 Section \u2192 Resource
|
|
7661
|
+
\`\`\`
|
|
7662
|
+
|
|
7663
|
+
**Examples for each level:**
|
|
7664
|
+
|
|
7665
|
+
| Level | Core Example | Extension Example |
|
|
7666
|
+
|-------|--------------|-------------------|
|
|
7667
|
+
| Context | \`${codePrefixes.core}administration\` | \`${codePrefixes.extension}it\` |
|
|
7668
|
+
| Application | \`${codePrefixes.core}settings\` | \`${codePrefixes.extension}custom_app\` |
|
|
7669
|
+
| Module | \`${codePrefixes.core}users\` | \`${codePrefixes.extension}inventory\` |
|
|
7670
|
+
| Section | \`${codePrefixes.core}management\` | \`${codePrefixes.extension}reports\` |
|
|
7671
|
+
| Resource | \`${codePrefixes.core}user_list\` | \`${codePrefixes.extension}stock_view\` |
|
|
7672
|
+
|
|
7673
|
+
**Rules:**
|
|
7674
|
+
1. \`${codePrefixes.core}*\` codes are **protected** - clients cannot create or modify them
|
|
7675
|
+
2. \`${codePrefixes.extension}*\` codes are **free** - clients can create custom navigation
|
|
7676
|
+
3. SmartStack updates will never overwrite \`${codePrefixes.extension}*\` data
|
|
7677
|
+
4. Codes must be unique within their level (e.g., no two Contexts with same code)
|
|
7678
|
+
|
|
7679
|
+
### Entity Configuration
|
|
7680
|
+
|
|
7681
|
+
\`\`\`csharp
|
|
7682
|
+
public class MyEntityConfiguration : IEntityTypeConfiguration<MyEntity>
|
|
7683
|
+
{
|
|
7684
|
+
public void Configure(EntityTypeBuilder<MyEntity> builder)
|
|
7685
|
+
{
|
|
7686
|
+
// CORRECT: Use schema + domain prefix
|
|
7687
|
+
builder.ToTable("auth_Users", "${schemas.platform}");
|
|
7688
|
+
|
|
7689
|
+
// WRONG: No schema specified
|
|
7690
|
+
// builder.ToTable("auth_Users");
|
|
7691
|
+
|
|
7692
|
+
// WRONG: No domain prefix
|
|
7693
|
+
// builder.ToTable("Users", "${schemas.platform}");
|
|
7694
|
+
}
|
|
7695
|
+
}
|
|
7696
|
+
\`\`\`
|
|
7697
|
+
|
|
7698
|
+
### Extending Core Entities
|
|
7699
|
+
|
|
7700
|
+
When extending a core entity, use the \`${schemas.extensions}\` schema:
|
|
7701
|
+
|
|
7702
|
+
\`\`\`csharp
|
|
7703
|
+
// Client extension for auth_Users
|
|
7704
|
+
public class UserExtension
|
|
7705
|
+
{
|
|
7706
|
+
public Guid UserId { get; set; } // FK to auth_Users
|
|
7707
|
+
public User User { get; set; }
|
|
7708
|
+
|
|
7709
|
+
// Custom properties
|
|
7710
|
+
public string CustomField { get; set; }
|
|
7711
|
+
}
|
|
7712
|
+
|
|
7713
|
+
// Configuration - use extensions schema
|
|
7714
|
+
builder.ToTable("client_UserExtensions", "${schemas.extensions}");
|
|
7715
|
+
builder.HasOne(e => e.User)
|
|
7716
|
+
.WithOne()
|
|
7717
|
+
.HasForeignKey<UserExtension>(e => e.UserId);
|
|
7718
|
+
\`\`\`
|
|
7719
|
+
|
|
7720
|
+
---
|
|
7721
|
+
|
|
7722
|
+
## 2. Migration Conventions
|
|
7723
|
+
|
|
7724
|
+
### Naming Format
|
|
7725
|
+
|
|
7726
|
+
Migrations MUST follow this naming pattern:
|
|
7727
|
+
|
|
7728
|
+
\`\`\`
|
|
7729
|
+
${migrationFormat}
|
|
7730
|
+
\`\`\`
|
|
7731
|
+
|
|
7732
|
+
| Part | Description | Example |
|
|
7733
|
+
|------|-------------|---------|
|
|
7734
|
+
| \`{context}\` | DbContext name | \`core\`, \`extensions\` |
|
|
7735
|
+
| \`{version}\` | Semver version | \`v1.0.0\`, \`v1.2.0\` |
|
|
7736
|
+
| \`{sequence}\` | Order in version | \`001\`, \`002\` |
|
|
7737
|
+
| \`{Description}\` | Action (PascalCase) | \`CreateAuthUsers\` |
|
|
7738
|
+
|
|
7739
|
+
**Examples:**
|
|
7740
|
+
- \`core_v1.0.0_001_InitialSchema.cs\`
|
|
7741
|
+
- \`core_v1.0.0_002_CreateAuthUsers.cs\`
|
|
7742
|
+
- \`core_v1.2.0_001_AddUserProfiles.cs\`
|
|
7743
|
+
- \`extensions_v1.0.0_001_AddClientFeatures.cs\`
|
|
7744
|
+
|
|
7745
|
+
### Creating Migrations
|
|
7746
|
+
|
|
7747
|
+
\`\`\`bash
|
|
7748
|
+
# Create a new migration
|
|
7749
|
+
dotnet ef migrations add core_v1.0.0_001_InitialSchema
|
|
7750
|
+
|
|
7751
|
+
# With context specified
|
|
7752
|
+
dotnet ef migrations add core_v1.2.0_001_AddUserProfiles --context ApplicationDbContext
|
|
7753
|
+
\`\`\`
|
|
7754
|
+
|
|
7755
|
+
### Migration Rules
|
|
7756
|
+
|
|
7757
|
+
1. **One migration per feature** - Group related changes in a single migration
|
|
3607
7758
|
2. **Version-based naming** - Use semver (v1.0.0, v1.2.0) to link migrations to releases
|
|
3608
7759
|
3. **Sequence numbers** - Use NNN (001, 002, etc.) for migrations in the same version
|
|
3609
7760
|
4. **Context prefix** - Use \`core_\` for platform tables, \`extensions_\` for client extensions
|
|
@@ -4432,7 +8583,7 @@ Run specific or all checks:
|
|
|
4432
8583
|
}
|
|
4433
8584
|
|
|
4434
8585
|
// src/resources/project-info.ts
|
|
4435
|
-
import
|
|
8586
|
+
import path17 from "path";
|
|
4436
8587
|
var projectInfoResourceTemplate = {
|
|
4437
8588
|
uri: "smartstack://project",
|
|
4438
8589
|
name: "SmartStack Project Info",
|
|
@@ -4469,16 +8620,16 @@ async function getProjectInfoResource(config) {
|
|
|
4469
8620
|
lines.push("```");
|
|
4470
8621
|
lines.push(`${projectInfo.name}/`);
|
|
4471
8622
|
if (structure.domain) {
|
|
4472
|
-
lines.push(`\u251C\u2500\u2500 ${
|
|
8623
|
+
lines.push(`\u251C\u2500\u2500 ${path17.basename(structure.domain)}/ # Domain layer (entities)`);
|
|
4473
8624
|
}
|
|
4474
8625
|
if (structure.application) {
|
|
4475
|
-
lines.push(`\u251C\u2500\u2500 ${
|
|
8626
|
+
lines.push(`\u251C\u2500\u2500 ${path17.basename(structure.application)}/ # Application layer (services)`);
|
|
4476
8627
|
}
|
|
4477
8628
|
if (structure.infrastructure) {
|
|
4478
|
-
lines.push(`\u251C\u2500\u2500 ${
|
|
8629
|
+
lines.push(`\u251C\u2500\u2500 ${path17.basename(structure.infrastructure)}/ # Infrastructure (EF Core)`);
|
|
4479
8630
|
}
|
|
4480
8631
|
if (structure.api) {
|
|
4481
|
-
lines.push(`\u251C\u2500\u2500 ${
|
|
8632
|
+
lines.push(`\u251C\u2500\u2500 ${path17.basename(structure.api)}/ # API layer (controllers)`);
|
|
4482
8633
|
}
|
|
4483
8634
|
if (structure.web) {
|
|
4484
8635
|
lines.push(`\u2514\u2500\u2500 web/smartstack-web/ # React frontend`);
|
|
@@ -4491,8 +8642,8 @@ async function getProjectInfoResource(config) {
|
|
|
4491
8642
|
lines.push("| Project | Path |");
|
|
4492
8643
|
lines.push("|---------|------|");
|
|
4493
8644
|
for (const csproj of projectInfo.csprojFiles) {
|
|
4494
|
-
const name =
|
|
4495
|
-
const relativePath =
|
|
8645
|
+
const name = path17.basename(csproj, ".csproj");
|
|
8646
|
+
const relativePath = path17.relative(projectPath, csproj);
|
|
4496
8647
|
lines.push(`| ${name} | \`${relativePath}\` |`);
|
|
4497
8648
|
}
|
|
4498
8649
|
lines.push("");
|
|
@@ -4502,10 +8653,10 @@ async function getProjectInfoResource(config) {
|
|
|
4502
8653
|
cwd: structure.migrations,
|
|
4503
8654
|
ignore: ["*.Designer.cs"]
|
|
4504
8655
|
});
|
|
4505
|
-
const migrations = migrationFiles.map((f) =>
|
|
8656
|
+
const migrations = migrationFiles.map((f) => path17.basename(f)).filter((f) => !f.includes("ModelSnapshot") && !f.includes(".Designer.")).sort();
|
|
4506
8657
|
lines.push("## EF Core Migrations");
|
|
4507
8658
|
lines.push("");
|
|
4508
|
-
lines.push(`**Location**: \`${
|
|
8659
|
+
lines.push(`**Location**: \`${path17.relative(projectPath, structure.migrations)}\``);
|
|
4509
8660
|
lines.push(`**Total Migrations**: ${migrations.length}`);
|
|
4510
8661
|
lines.push("");
|
|
4511
8662
|
if (migrations.length > 0) {
|
|
@@ -4540,11 +8691,11 @@ async function getProjectInfoResource(config) {
|
|
|
4540
8691
|
lines.push("dotnet build");
|
|
4541
8692
|
lines.push("");
|
|
4542
8693
|
lines.push("# Run API");
|
|
4543
|
-
lines.push(`cd ${structure.api ?
|
|
8694
|
+
lines.push(`cd ${structure.api ? path17.relative(projectPath, structure.api) : "SmartStack.Api"}`);
|
|
4544
8695
|
lines.push("dotnet run");
|
|
4545
8696
|
lines.push("");
|
|
4546
8697
|
lines.push("# Run frontend");
|
|
4547
|
-
lines.push(`cd ${structure.web ?
|
|
8698
|
+
lines.push(`cd ${structure.web ? path17.relative(projectPath, structure.web) : "web/smartstack-web"}`);
|
|
4548
8699
|
lines.push("npm run dev");
|
|
4549
8700
|
lines.push("");
|
|
4550
8701
|
lines.push("# Create migration");
|
|
@@ -4567,7 +8718,7 @@ async function getProjectInfoResource(config) {
|
|
|
4567
8718
|
}
|
|
4568
8719
|
|
|
4569
8720
|
// src/resources/api-endpoints.ts
|
|
4570
|
-
import
|
|
8721
|
+
import path18 from "path";
|
|
4571
8722
|
var apiEndpointsResourceTemplate = {
|
|
4572
8723
|
uri: "smartstack://api/",
|
|
4573
8724
|
name: "SmartStack API Endpoints",
|
|
@@ -4592,7 +8743,7 @@ async function getApiEndpointsResource(config, endpointFilter) {
|
|
|
4592
8743
|
}
|
|
4593
8744
|
async function parseController(filePath, _rootPath) {
|
|
4594
8745
|
const content = await readText(filePath);
|
|
4595
|
-
const fileName =
|
|
8746
|
+
const fileName = path18.basename(filePath, ".cs");
|
|
4596
8747
|
const controllerName = fileName.replace("Controller", "");
|
|
4597
8748
|
const endpoints = [];
|
|
4598
8749
|
const routeMatch = content.match(/\[Route\s*\(\s*"([^"]+)"\s*\)\]/);
|
|
@@ -4739,7 +8890,7 @@ function getMethodEmoji(method) {
|
|
|
4739
8890
|
}
|
|
4740
8891
|
|
|
4741
8892
|
// src/resources/db-schema.ts
|
|
4742
|
-
import
|
|
8893
|
+
import path19 from "path";
|
|
4743
8894
|
var dbSchemaResourceTemplate = {
|
|
4744
8895
|
uri: "smartstack://schema/",
|
|
4745
8896
|
name: "SmartStack Database Schema",
|
|
@@ -4829,7 +8980,7 @@ async function parseEntity(filePath, rootPath, _config) {
|
|
|
4829
8980
|
tableName,
|
|
4830
8981
|
properties,
|
|
4831
8982
|
relationships,
|
|
4832
|
-
file:
|
|
8983
|
+
file: path19.relative(rootPath, filePath)
|
|
4833
8984
|
};
|
|
4834
8985
|
}
|
|
4835
8986
|
async function enrichFromConfigurations(entities, infrastructurePath, _config) {
|
|
@@ -4975,7 +9126,7 @@ function formatSchema(entities, filter, _config) {
|
|
|
4975
9126
|
}
|
|
4976
9127
|
|
|
4977
9128
|
// src/resources/entities.ts
|
|
4978
|
-
import
|
|
9129
|
+
import path20 from "path";
|
|
4979
9130
|
var entitiesResourceTemplate = {
|
|
4980
9131
|
uri: "smartstack://entities/",
|
|
4981
9132
|
name: "SmartStack Entities",
|
|
@@ -5035,7 +9186,7 @@ async function parseEntitySummary(filePath, rootPath, config) {
|
|
|
5035
9186
|
hasSoftDelete,
|
|
5036
9187
|
hasRowVersion,
|
|
5037
9188
|
file: filePath,
|
|
5038
|
-
relativePath:
|
|
9189
|
+
relativePath: path20.relative(rootPath, filePath)
|
|
5039
9190
|
};
|
|
5040
9191
|
}
|
|
5041
9192
|
function inferTableInfo(entityName, config) {
|
|
@@ -5222,7 +9373,16 @@ async function createServer() {
|
|
|
5222
9373
|
checkMigrationsTool,
|
|
5223
9374
|
scaffoldExtensionTool,
|
|
5224
9375
|
apiDocsTool,
|
|
5225
|
-
suggestMigrationTool
|
|
9376
|
+
suggestMigrationTool,
|
|
9377
|
+
// Test Tools
|
|
9378
|
+
scaffoldTestsTool,
|
|
9379
|
+
analyzeTestCoverageTool,
|
|
9380
|
+
validateTestConventionsTool,
|
|
9381
|
+
suggestTestScenariosTool,
|
|
9382
|
+
// Frontend Route Tools
|
|
9383
|
+
scaffoldApiClientTool,
|
|
9384
|
+
scaffoldRoutesTool,
|
|
9385
|
+
validateFrontendRoutesTool
|
|
5226
9386
|
]
|
|
5227
9387
|
};
|
|
5228
9388
|
});
|
|
@@ -5248,6 +9408,29 @@ async function createServer() {
|
|
|
5248
9408
|
case "suggest_migration":
|
|
5249
9409
|
result = await handleSuggestMigration(args, config);
|
|
5250
9410
|
break;
|
|
9411
|
+
// Test Tools
|
|
9412
|
+
case "scaffold_tests":
|
|
9413
|
+
result = await handleScaffoldTests(args, config);
|
|
9414
|
+
break;
|
|
9415
|
+
case "analyze_test_coverage":
|
|
9416
|
+
result = await handleAnalyzeTestCoverage(args, config);
|
|
9417
|
+
break;
|
|
9418
|
+
case "validate_test_conventions":
|
|
9419
|
+
result = await handleValidateTestConventions(args, config);
|
|
9420
|
+
break;
|
|
9421
|
+
case "suggest_test_scenarios":
|
|
9422
|
+
result = await handleSuggestTestScenarios(args, config);
|
|
9423
|
+
break;
|
|
9424
|
+
// Frontend Route Tools
|
|
9425
|
+
case "scaffold_api_client":
|
|
9426
|
+
result = await handleScaffoldApiClient(args ?? {}, config);
|
|
9427
|
+
break;
|
|
9428
|
+
case "scaffold_routes":
|
|
9429
|
+
result = await handleScaffoldRoutes(args ?? {}, config);
|
|
9430
|
+
break;
|
|
9431
|
+
case "validate_frontend_routes":
|
|
9432
|
+
result = await handleValidateFrontendRoutes(args ?? {}, config);
|
|
9433
|
+
break;
|
|
5251
9434
|
default:
|
|
5252
9435
|
throw new Error(`Unknown tool: ${name}`);
|
|
5253
9436
|
}
|