Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .agent/rules/backend/api-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
trigger: glob
globs: **/Endpoints/*.cs,*Endpoints.cs
description: Rules for ASP.NET minimal API endpoints
---
# API Endpoints

Carefully follow these instructions when implementing minimal API endpoints in the backend, including structure, route conventions, and usage patterns.

## Implementation

1. Create API endpoint classes in `/application/[scs-name]/Api/Endpoints`, organized by feature area
2. Create an endpoint class implementing `IEndpoints` with proper naming (`[Feature]Endpoints.cs`)
3. Define a constant string for `RoutesPrefix`: `/api/[scs-name]/[Feature]`:
```csharp
private const string RoutesPrefix = "/api/account-management/users";
```
4. Set up the route group with a tag name, `.RequireAuthorization()`, and `.ProducesValidationProblem()`:
```csharp
var group = routes.MapGroup(RoutesPrefix).WithTags("Users").RequireAuthorization().ProducesValidationProblem();
```
5. Structure each endpoint in exactly 3 lines (no logic in the body):
- Line 1: Signature with route and parameters (don't break the line even if longer than 120 characters)
- Line 2: Expression calling `=> mediator.Send()`
- Line 3: Optional configuration (`.Produces<T>()`, `.AllowAnonymous()`, etc.)
6. Follow these requirements:
- Use [Strongly Typed IDs](/.agent/rules/backend/strongly-typed-ids.md) for route parameters
- Return `ApiResult<T>` for queries and `ApiResult` or `IRequest<Result<T>>` for commands
- Use `[AsParameters]` for query parameters
- Use `with { Id = id }` syntax to bind route parameters to commands and queries
7. After changing the API, run `build --backend` to generate the OpenAPI JSON contract, then `build --frontend` to trigger `openapi-typescript`
8. `IEndpoints` are automatically registered in the SharedKernel

## Examples

### Example 1 - User Endpoints

```csharp
// ✅ DO: Structure endpoints in exactly 3 lines with no logic in the body
public sealed class UserEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/account-management/users";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(RoutesPrefix).WithTags("Users").RequireAuthorization().ProducesValidationProblem();

// ✅ DO: Use [AsParameters] for complex queries with many querystring parameters
group.MapGet("/", async Task<ApiResult<GetUsersResponse>> ([AsParameters] GetUsersQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<GetUsersResponse>();

group.MapDelete("/{id}", async Task<ApiResult> (UserId id, IMediator mediator)
=> await mediator.Send(new DeleteUserCommand(id))
);

group.MapPost("/bulk-delete", async Task<ApiResult> (BulkDeleteUsersCommand command, IMediator mediator)
=> await mediator.Send(command)
);

// ✅ DO: Use [AsParameters] even when the query has no parameters
group.MapGet("/me", async Task<ApiResult<UserResponse>> ([AsParameters] GetUserQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<UserResponse>(); // ✅ DO: Add produces when API returns a strongly typed response
}
}

// ❌ DON'T: Add business logic inside endpoint methods or break the 3-line structure
public sealed class BadUserEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/account-management/users";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(RoutesPrefix).WithTags("Users"); // ❌ DON'T: Skip .RequireAuthorization() even if all endpoints AllowAnonymous

group.MapGet("/", async (IMediator mediator, HttpContext context) =>
{
// ❌ DON'T: Add business logic inside endpoint methods
var tenantId = context.User.GetTenantId();
var query = new GetUsersQuery { TenantId = tenantId };
var result = await mediator.Send(query);
return Results.Ok(result);
});

// ❌ DON'T: Use Put for commands that do not update an existing resource
group.MapPut("/{id}/change-user-role", async Task<ApiResult> (
UserId id,
ChangeUserRoleCommand command,
IMediator mediator
) // ❌ DON'T: Break the line even if it extends 120 characters
=> await mediator.Send(command with { Id = id })
);

// ❌ DON'T: Use MVC [FromBody] attribute
group.MapPost("/bulk-delete", async Task<ApiResult> ([FromBody] BulkDeleteUsersCommand command, IMediator mediator)
=> await mediator.Send(command)
).Produces<UserId>(StatusCodes.Status201Created) // ❌ DON'T: Add produces status code
.ProducesProblem(StatusCodes.Status403Forbidden) // ❌ DON'T: Add produces status code
.ProducesProblem(StatusCodes.Status409Conflict);

// ❌ Forgot leading slash, newing up query instead of using [AsParameters]
group.MapGet("me", async Task<ApiResult<UserResponse>> (IMediator mediator)
=> await mediator.Send(new GetUserQuery())
).Produces<UserResponse>();
}
}
```
106 changes: 106 additions & 0 deletions .agent/rules/backend/api-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
trigger: glob
globs: **/Tests/*.cs
description: Rules for writing backend API tests
---
# Writing API Tests

Carefully follow these instructions when writing tests for the backend. By default, tests should test API endpoints to verify behavior over implementation. Only in rare cases should unit tests be used.

## Implementation

1. Follow these naming conventions:
- Test files: `[Feature]/[Command|Query]Tests.cs`
- Test classes: `[Command|Query]Tests` and be `sealed`
- Test methods: `[Method]_[Condition]_[ExpectedResult]`
2. Organize tests by feature area in directories matching the feature structure—do not create a `/features/` top-level folder
3. For endpoint tests, inherit from `EndpointBaseTest<TContext>` for access to HTTP clients and test infrastructure
4. Prefer API tests to verify behavior over implementation:
- Use `AuthenticatedOwnerHttpClient` or `AuthenticatedMemberHttpClient` for authenticated requests
- Use `AnonymousHttpClient` for anonymous requests
5. Use xUnit with `[Fact]` attribute or `[Theory]` if multiple test cases are needed
6. Use FluentAssertions for clear assertion syntax
7. Use Bogus (Faker) to generate random test data instead of hardcoded values
8. Use NSubstitute for mocking external dependencies but never mock repositories
9. Follow the Arrange-Act-Assert pattern with clear comments:
- Only use these three comment sections: `// Arrange`, `// Act`, and `// Assert`
- Only include `// Arrange` when there is actually setup code
- Do not add additional comments for subsections (e.g., no `// Setup database` or `// Verify telemetry events`)
10. Test both happy path and error cases
11. Avoid sharing fields between tests—prefer local constants or variables within each test method
12. Verify side effects like database changes and telemetry events
13. Always call `TelemetryEventsCollectorSpy.Reset()` as the last Arrange statement if API calls were used to set up state
14. Use the `Connection` property from `EndpointBaseTest<TContext>` for test data—it provides a SQLite connection with:
- `Insert` to populate test data
- `Update` to update test data
- `Delete` to delete test data
- `ExecuteScalar<T>` to verify data was correctly inserted
- `RowExists` to check if specific records exist
15. Never use Dapper for database operations in tests—this is the main reason for rejected tests
16. The `EndpointBaseTest<TContext>` class provides:
- Authenticated and anonymous HTTP clients
- In-memory SQLite database for test isolation
- Service mocking with NSubstitute
- Telemetry event collection
- Proper test cleanup with the Dispose pattern

IMPORTANT: Ensure consistent ordering, naming, spacing, and line breaks. When creating SQL dummy data, ensure columns are in the exact same order as in the database. Make sure similar elements are written consistently across tests.

## Examples

```csharp
// ✅ DO: Use Arrange-Act-Assert, proper naming, FluentAssertions, and verify side effects
[Fact]
public async Task CompleteLogin_WhenValid_ShouldCompleteLoginAndCreateTokens()
{
// Arrange
var (loginId, _) = await StartLogin(DatabaseSeeder.User1.Email); // ✅ DO: Use test helpers for setup
var command = new CompleteLoginCommand(CorrectOneTimePassword);
TelemetryEventsCollectorSpy.Reset(); // ✅ DO: Reset telemetry if API was called in Arrange

// Act
var response = await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command);

// Assert
await response.ShouldBeSuccessfulPostRequest(hasLocation: false); // ✅ DO: Use custom assertion helpers
Connection.ExecuteScalar("SELECT COUNT(*) FROM Logins WHERE Id = @id AND Completed = 1", new { id = loginId.ToString() }).Should().Be(1); // ✅ DO: Verify DB side effects
TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); // ✅ DO: Verify telemetry
TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("LoginStarted");
TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("LoginCompleted"); // ✅ DO: Verify the correct events were collected
}

// ❌ DON'T: Mix Arrange-Act-Assert, use unclear naming, or skip side effects
[Fact]
public async Task BadTest()
{
var response = await AuthenticatedMemberHttpClient.GetAsync("/api/account-management/users?search=willgate"); // ❌ Unclear test name, no Arrange/Act/Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ❌ DON'T: Use basic assertions instead of FluentAssertions
// ❌ DON'T: Skip verifying DB or telemetry side effects
}

// ✅ DO: Use SQLite helpers for test data setup, consistent column order
public GetUsersTests()
{
Connection.Insert("Users", [
("TenantId", DatabaseSeeder.Tenant1.Id.ToString()),
("Id", UserId.NewId().ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), // ✅ DO: Use TimeProvider for dates
("ModifiedAt", null),
("Email", Email)
]);
}

// ❌ DON'T: Use Dapper in tests
public class BadTestSetup
{
public BadTestSetup() // ❌ DON'T: Add setup logic to constructor
{
// Arrange
using var connection = new SqliteConnection(Connection.ConnectionString); // ❌ DON'T: Use Dapper
connection.Open();

// Insert user // ❌ DON'T: Add comments
connection.Execute("INSERT INTO Users (Email, Id, TenantId) VALUES (@Email, @Id, @TenantId)", new { Email = "test@example.com", Id = Guid.NewGuid(), TenantId = 1 });
}
}
```
118 changes: 118 additions & 0 deletions .agent/rules/backend/backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
trigger: glob
globs: *.cs,*.csproj,*.slnx,Directory.Packages.props,global.json,dotnet-tools.json
description: Core rules for C# development and tooling
---
# Backend

Carefully follow these instructions for C# backend development, including code style, naming, exceptions, logging, and build/test/format workflow.

## Code Style

- Be consistent—if you do something a certain way, do all similar things the same way
- Always use these C# features:
- Top-level namespaces
- Primary constructors
- Array initializers
- Pattern matching with `is null` and `is not null` instead of `== null` and `!= null`
- Records for immutable types
- Mark all C# types as sealed
- Use `var` when possible
- Use simple collection types like `UserId[]` instead of `List<UserId>` whenever possible
- JetBrains tooling formats code automatically, but line breaking is disabled for readability:
- Wrap lines only when new language constructs start after 120 characters (content after 120 chars is acceptable if the construct starts before)
- `CancellationToken cancellationToken` is not considered important and should never trigger a line break
- Prefer long lines over splitting to maximize visible code—only wrap when truly necessary
- Examples:
```csharp
// ✅ DO: Keep on one line even if longer than 120 chars (the 'c' in 'command' is before 120 chars)
public async Task<Result<CompleteEmailConfirmationResponse>> Handle(CompleteEmailConfirmationCommand command, CancellationToken cancellationToken)

// ✅ DO: Keep constructor parameters on one line when they fit (the 'e' in 'executionContext' is before 120 chars)
public sealed class GetPaymentHistoryHandler(ISubscriptionRepository subscriptionRepository, IExecutionContext executionContext)
: IRequestHandler<GetPaymentHistoryQuery, Result<PaymentHistoryResponse>>

// ✅ DO: Wrap to 2 lines when needed, but never 3, 4, or 5 lines
var updatedLocale = Connection.ExecuteScalar<string>(
"SELECT Locale FROM Users WHERE Id = @id", new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() }
);

// ❌ DON'T: Split method parameters across multiple lines when they fit before 120 chars
public async Task<Result> Handle(
UpgradeSubscriptionCommand command,
CancellationToken cancellationToken
)

// ❌ DON'T: Split constructor parameters across multiple lines when they fit before 120 chars
public sealed class GetPaymentHistoryHandler(
ISubscriptionRepository subscriptionRepository,
IExecutionContext executionContext
) : IRequestHandler<GetPaymentHistoryQuery, Result<PaymentHistoryResponse>>
```
- Avoid using exceptions for control flow:
- When throwing exceptions, use meaningful exceptions following .NET conventions
- Use `UnreachableException` to signal unreachable code that cannot be reached by tests
- Exception messages should include a period
- Log only meaningful events at appropriate severity levels:
- Logging messages should not include a period
- Use structured logging
- Never introduce new NuGet dependencies
- Don't do defensive coding (e.g., don't add exception handling for situations we don't know will happen)
- Use `user?.IsActive == true` over `user != null && user.IsActive == true`
- Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions
- Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure
- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()`
- Naming rules:
- Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`)
- Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`)
- Choose descriptive and unambiguous names
- Make meaningful distinctions
- Use pronounceable names
- Use searchable names
- Replace magic numbers with named constants
- Avoid encodings—don't append prefixes or type information
- Comments rules:
- Don't explain what you changed (that belongs in commit messages)—code should reflect the current state only
- Always try to explain yourself in code
- Don't be redundant
- Don't add obvious noise
- Don't use closing brace comments
- Don't comment out code—just remove it
- Use comments for explanation of intent, clarification, or warning of consequences
- Source code structure:
- Separate concepts vertically
- Related code should appear vertically dense
- Declare variables close to their usage
- Dependent functions should be close
- Similar functions should be close
- Place functions in the downward direction
- Order: public functions above internal, internal above private
- Don't use horizontal alignment
- Use white space to associate related things and disassociate weakly related
- Avoid nesting—prefer early return or break/continue statements, keeping the happy path at the end
- Functions rules:
- Keep them small
- Do one thing
- Use descriptive names
- Prefer fewer arguments
- Have no side effects
- Don't use flag arguments—split into independent methods instead
- For enum comparisons:
- When comparing enums to enums, use direct comparison: `tenant.State == tenantState.Trial`
- When comparing string properties to enums (e.g., JWT claims), use `nameof`: `executionContext.UserInfo.Role == nameof(UserRole.Owner)`
- Avoid unnecessary `Enum.TryParse` when the comparison context is clear

## Implementation

IMPORTANT: Always follow these steps very carefully when implementing changes:

1. Always start new changes by writing new test cases (or change existing tests)—consult [API Tests](/.agent/rules/backend/api-tests.md) for details
2. Build and test your changes:
- Use the **execute MCP tool** with `command: "build"` for backend
- Use the **execute MCP tool** with `command: "test"` to run all tests
- If you change API contracts (endpoints, DTOs), also build frontend to ensure it still compiles
3. Format your code:
- When all tests pass and the feature is complete, use the **execute MCP tool** with `command: "format"` for backend
- The format tool will automatically fix code style issues according to our conventions

When you see paths like `/[scs-name]/Core/Features/[Feature]/Domain` in rules, replace `[scs-name]` with the specific self-contained system name (e.g., `account-management`, `back-office`) and `[Feature]` with the feature name (e.g., `Users`, `Tenants`). A feature is often 1:1 with a domain aggregate.
Loading