@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 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 fs from "fs-extra";
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, path14, cause) {
85
+ constructor(message, operation, path21, cause) {
86
86
  super(message);
87
87
  this.operation = operation;
88
- this.path = path14;
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 stat = await fs.stat(filePath);
107
- return stat.isFile();
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 stat = await fs.stat(dirPath);
115
- return stat.isDirectory();
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 fs.ensureDir(dirPath);
121
+ await mkdir(dirPath, { recursive: true });
122
122
  }
123
123
  async function readJson(filePath) {
124
124
  try {
125
- const content = await fs.readFile(filePath, "utf-8");
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 fs.readFile(filePath, "utf-8");
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 fs.ensureDir(path.dirname(filePath));
165
- await fs.writeFile(filePath, content, "utf-8");
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 serviceTestTemplate = `using System;
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(serviceTestTemplate)(context);
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/resources/conventions.ts
3450
- var conventionsResourceTemplate = {
3451
- uri: "smartstack://conventions",
3452
- name: "AtlasHub Conventions",
3453
- description: "Documentation of AtlasHub/SmartStack naming conventions, patterns, and best practices",
3454
- mimeType: "text/markdown"
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
- async function getConventionsResource(config) {
3457
- const { schemas, tablePrefixes, codePrefixes, migrationFormat, namespaces, servicePattern } = config.conventions;
3458
- return `# AtlasHub SmartStack Conventions
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
- ## Overview
3613
+ namespace {{testNamespace}}.Unit.Domain;
3461
3614
 
3462
- This document describes the mandatory conventions for extending the SmartStack/AtlasHub platform.
3463
- Following these conventions ensures compatibility and prevents conflicts.
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
- ## 1. Database Conventions
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
- ### SQL Schemas
3637
+ // Act
3638
+ var entity = {{name}}.Create({{#unless isSystemEntity}}tenantId, {{/unless}}code);
3470
3639
 
3471
- SmartStack uses SQL Server schemas to separate platform tables from client extensions:
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
- | Schema | Usage | Description |
3474
- |--------|-------|-------------|
3475
- | \`${schemas.platform}\` | SmartStack platform | All native SmartStack tables |
3476
- | \`${schemas.extensions}\` | Client extensions | Custom tables added by clients |
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
- ### Domain Table Prefixes
3659
+ // Act
3660
+ var act = () => {{name}}.Create(tenantId, code);
3479
3661
 
3480
- Tables are organized by domain using prefixes:
3662
+ // Assert
3663
+ act.Should().Throw<ArgumentException>()
3664
+ .WithParameterName("tenantId");
3665
+ }
3666
+ {{/unless}}
3481
3667
 
3482
- | Prefix | Domain | Example Tables |
3483
- |--------|--------|----------------|
3484
- | \`auth_\` | Authorization | auth_Users, auth_Roles, auth_Permissions |
3485
- | \`nav_\` | Navigation | nav_Contexts, nav_Applications, nav_Modules |
3486
- | \`usr_\` | User profiles | usr_Profiles, usr_Preferences |
3487
- | \`ai_\` | AI features | ai_Providers, ai_Models, ai_Prompts |
3488
- | \`cfg_\` | Configuration | cfg_Settings |
3489
- | \`wkf_\` | Workflows | wkf_EmailTemplates, wkf_Workflows |
3490
- | \`support_\` | Support | support_Tickets, support_Comments |
3491
- | \`entra_\` | Entra sync | entra_Groups, entra_SyncState |
3492
- | \`ref_\` | References | ref_Companies, ref_Departments |
3493
- | \`loc_\` | Localization | loc_Languages, loc_Translations |
3494
- | \`lic_\` | Licensing | lic_Licenses |
3495
- | \`tenant_\` | Multi-Tenancy | tenant_Tenants, tenant_TenantUsers, tenant_TenantUserRoles |
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
- ### Navigation Code Prefixes
3678
+ // Act
3679
+ var entity = {{name}}.Create({{#unless isSystemEntity}}tenantId, {{/unless}}code);
3498
3680
 
3499
- All navigation data (Context, Application, Module, Section, Resource) uses code prefixes to distinguish system data from client extensions:
3681
+ // Assert
3682
+ entity.Should().NotBeNull();
3683
+ entity.Code.Should().BeEmpty();
3684
+ }
3500
3685
 
3501
- | Origin | Prefix | Usage | Example |
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
- **Navigation Hierarchy (5 levels):**
3688
+ {{#if includeSoftDelete}}
3689
+ #region Soft Delete Tests
3507
3690
 
3508
- \`\`\`
3509
- Context \u2192 Application \u2192 Module \u2192 Section \u2192 Resource
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
- **Examples for each level:**
3698
+ // Act
3699
+ entity.SoftDelete(deletedBy);
3513
3700
 
3514
- | Level | Core Example | Extension Example |
3515
- |-------|--------------|-------------------|
3516
- | Context | \`${codePrefixes.core}administration\` | \`${codePrefixes.extension}it\` |
3517
- | Application | \`${codePrefixes.core}settings\` | \`${codePrefixes.extension}custom_app\` |
3518
- | Module | \`${codePrefixes.core}users\` | \`${codePrefixes.extension}inventory\` |
3519
- | Section | \`${codePrefixes.core}management\` | \`${codePrefixes.extension}reports\` |
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
- **Rules:**
3523
- 1. \`${codePrefixes.core}*\` codes are **protected** - clients cannot create or modify them
3524
- 2. \`${codePrefixes.extension}*\` codes are **free** - clients can create custom navigation
3525
- 3. SmartStack updates will never overwrite \`${codePrefixes.extension}*\` data
3526
- 4. Codes must be unique within their level (e.g., no two Contexts with same code)
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
- ### Entity Configuration
3716
+ // Act
3717
+ entity.SoftDelete("second_deleter");
3529
3718
 
3530
- \`\`\`csharp
3531
- public class MyEntityConfiguration : IEntityTypeConfiguration<MyEntity>
3532
- {
3533
- public void Configure(EntityTypeBuilder<MyEntity> builder)
3719
+ // Assert
3720
+ entity.DeletedAt.Should().Be(originalDeletedAt);
3721
+ }
3722
+
3723
+ [Fact]
3724
+ public void Restore_WhenSoftDeleted_ShouldClearDeletedFields()
3534
3725
  {
3535
- // CORRECT: Use schema + domain prefix
3536
- builder.ToTable("auth_Users", "${schemas.platform}");
3726
+ // Arrange
3727
+ var entity = {{name}}.Create({{#unless isSystemEntity}}_tenantId, {{/unless}}ValidCode);
3728
+ entity.SoftDelete("deleter");
3729
+ var restoredBy = "restorer";
3537
3730
 
3538
- // WRONG: No schema specified
3539
- // builder.ToTable("auth_Users");
3731
+ // Act
3732
+ entity.Restore(restoredBy);
3540
3733
 
3541
- // WRONG: No domain prefix
3542
- // builder.ToTable("Users", "${schemas.platform}");
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
- ### Extending Core Entities
3741
+ #endregion
3742
+ {{/if}}
3548
3743
 
3549
- When extending a core entity, use the \`${schemas.extensions}\` schema:
3744
+ {{#if includeAudit}}
3745
+ #region Audit Trail Tests
3550
3746
 
3551
- \`\`\`csharp
3552
- // Client extension for auth_Users
3553
- public class UserExtension
3554
- {
3555
- public Guid UserId { get; set; } // FK to auth_Users
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
- // Custom properties
3559
- public string CustomField { get; set; }
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
- // Configuration - use extensions schema
3563
- builder.ToTable("client_UserExtensions", "${schemas.extensions}");
3564
- builder.HasOne(e => e.User)
3565
- .WithOne()
3566
- .HasForeignKey<UserExtension>(e => e.UserId);
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
- ## 2. Migration Conventions
3767
+ // Assert
3768
+ entity.CreatedBy.Should().Be(createdBy);
3769
+ }
3572
3770
 
3573
- ### Naming Format
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
- Migrations MUST follow this naming pattern:
3779
+ // Act
3780
+ entity.Update(updatedBy);
3576
3781
 
3577
- \`\`\`
3578
- ${migrationFormat}
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
- | Part | Description | Example |
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
- **Examples:**
3589
- - \`core_v1.0.0_001_InitialSchema.cs\`
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
- ### Creating Migrations
3795
+ [Fact]
3796
+ public void Create_WithSpecialCharactersInCode_ShouldNormalize()
3797
+ {
3798
+ // Arrange
3799
+ var code = "Test-Code_123";
3595
3800
 
3596
- \`\`\`bash
3597
- # Create a new migration
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
- # With context specified
3601
- dotnet ef migrations add core_v1.2.0_001_AddUserProfiles --context ApplicationDbContext
3602
- \`\`\`
3804
+ // Assert
3805
+ entity.Code.Should().Be(code.ToLowerInvariant());
3806
+ }
3603
3807
 
3604
- ### Migration Rules
3808
+ [Fact]
3809
+ public void Create_WithMaxLengthCode_ShouldSucceed()
3810
+ {
3811
+ // Arrange
3812
+ var code = new string('a', 100);
3605
3813
 
3606
- 1. **One migration per feature** - Group related changes in a single migration
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 path10 from "path";
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 ${path10.basename(structure.domain)}/ # Domain layer (entities)`);
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 ${path10.basename(structure.application)}/ # Application layer (services)`);
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 ${path10.basename(structure.infrastructure)}/ # Infrastructure (EF Core)`);
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 ${path10.basename(structure.api)}/ # API layer (controllers)`);
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 = path10.basename(csproj, ".csproj");
4495
- const relativePath = path10.relative(projectPath, csproj);
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) => path10.basename(f)).filter((f) => !f.includes("ModelSnapshot") && !f.includes(".Designer.")).sort();
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**: \`${path10.relative(projectPath, structure.migrations)}\``);
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 ? path10.relative(projectPath, structure.api) : "SmartStack.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 ? path10.relative(projectPath, structure.web) : "web/smartstack-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 path11 from "path";
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 = path11.basename(filePath, ".cs");
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 path12 from "path";
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: path12.relative(rootPath, filePath)
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 path13 from "path";
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: path13.relative(rootPath, filePath)
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
  }