feat: initial Claude Code configuration scaffold
Comprehensive Claude Code guidance system with: - 5 agents: tdd-guardian, code-reviewer, security-scanner, refactor-scan, dependency-audit - 18 skills covering languages (Python, TypeScript, Rust, Go, Java, C#), infrastructure (AWS, Azure, GCP, Terraform, Ansible, Docker/K8s, Database, CI/CD), testing (TDD, UI, Browser), and patterns (Monorepo, API Design, Observability) - 3 hooks: secret detection, auto-formatting, TDD git pre-commit - Strict TDD enforcement with 80%+ coverage requirements - Multi-model strategy: Opus for planning, Sonnet for execution (opusplan) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
666
.claude/skills/languages/csharp/SKILL.md
Normal file
666
.claude/skills/languages/csharp/SKILL.md
Normal file
@@ -0,0 +1,666 @@
|
||||
---
|
||||
name: csharp-development
|
||||
description: C# and .NET development patterns with xUnit, Clean Architecture, and modern C# practices. Use when writing C# code.
|
||||
---
|
||||
|
||||
# C# Development Skill
|
||||
|
||||
## Project Structure (Clean Architecture)
|
||||
|
||||
```
|
||||
Solution/
|
||||
├── src/
|
||||
│ ├── Domain/ # Enterprise business rules
|
||||
│ │ ├── Entities/
|
||||
│ │ ├── ValueObjects/
|
||||
│ │ ├── Exceptions/
|
||||
│ │ └── Domain.csproj
|
||||
│ ├── Application/ # Application business rules
|
||||
│ │ ├── Common/
|
||||
│ │ │ ├── Interfaces/
|
||||
│ │ │ └── Behaviours/
|
||||
│ │ ├── Features/
|
||||
│ │ │ └── Users/
|
||||
│ │ │ ├── Commands/
|
||||
│ │ │ └── Queries/
|
||||
│ │ └── Application.csproj
|
||||
│ ├── Infrastructure/ # External concerns
|
||||
│ │ ├── Persistence/
|
||||
│ │ ├── Identity/
|
||||
│ │ └── Infrastructure.csproj
|
||||
│ └── Api/ # Presentation layer
|
||||
│ ├── Controllers/
|
||||
│ ├── Middleware/
|
||||
│ └── Api.csproj
|
||||
├── tests/
|
||||
│ ├── Domain.Tests/
|
||||
│ ├── Application.Tests/
|
||||
│ ├── Infrastructure.Tests/
|
||||
│ └── Api.Tests/
|
||||
└── Solution.sln
|
||||
```
|
||||
|
||||
## Testing with xUnit
|
||||
|
||||
### Unit Tests with Fluent Assertions
|
||||
```csharp
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Application.Tests.Features.Users;
|
||||
|
||||
public class CreateUserCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IUserRepository> _userRepositoryMock;
|
||||
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
|
||||
private readonly CreateUserCommandHandler _handler;
|
||||
|
||||
public CreateUserCommandHandlerTests()
|
||||
{
|
||||
_userRepositoryMock = new Mock<IUserRepository>();
|
||||
_unitOfWorkMock = new Mock<IUnitOfWork>();
|
||||
_handler = new CreateUserCommandHandler(
|
||||
_userRepositoryMock.Object,
|
||||
_unitOfWorkMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCreateUser()
|
||||
{
|
||||
// Arrange
|
||||
var command = UserFixtures.CreateUserCommand();
|
||||
_userRepositoryMock
|
||||
.Setup(x => x.ExistsByEmailAsync(command.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().NotBeEmpty();
|
||||
result.Email.Should().Be(command.Email);
|
||||
|
||||
_userRepositoryMock.Verify(
|
||||
x => x.AddAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_unitOfWorkMock.Verify(
|
||||
x => x.SaveChangesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithDuplicateEmail_ShouldThrowDuplicateEmailException()
|
||||
{
|
||||
// Arrange
|
||||
var command = UserFixtures.CreateUserCommand();
|
||||
_userRepositoryMock
|
||||
.Setup(x => x.ExistsByEmailAsync(command.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var act = () => _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await act.Should()
|
||||
.ThrowAsync<DuplicateEmailException>()
|
||||
.WithMessage($"*{command.Email}*");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Fixtures
|
||||
```csharp
|
||||
namespace Application.Tests.Fixtures;
|
||||
|
||||
public static class UserFixtures
|
||||
{
|
||||
public static User User(Action<User>? customize = null)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = "test@example.com",
|
||||
Name = "Test User",
|
||||
Role = Role.User,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
customize?.Invoke(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
public static CreateUserCommand CreateUserCommand(
|
||||
Action<CreateUserCommand>? customize = null)
|
||||
{
|
||||
var command = new CreateUserCommand
|
||||
{
|
||||
Email = "newuser@example.com",
|
||||
Name = "New User",
|
||||
Password = "SecurePass123!"
|
||||
};
|
||||
|
||||
customize?.Invoke(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
public static UserDto UserDto(Action<UserDto>? customize = null)
|
||||
{
|
||||
var dto = new UserDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = "test@example.com",
|
||||
Name = "Test User",
|
||||
Role = Role.User.ToString()
|
||||
};
|
||||
|
||||
customize?.Invoke(dto);
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
var adminUser = UserFixtures.User(u => u.Role = Role.Admin);
|
||||
var customCommand = UserFixtures.CreateUserCommand(c => c.Email = "custom@example.com");
|
||||
```
|
||||
|
||||
### Theory Tests (Parameterized)
|
||||
```csharp
|
||||
namespace Domain.Tests.ValueObjects;
|
||||
|
||||
public class EmailTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("user@example.com", true)]
|
||||
[InlineData("user.name@example.co.uk", true)]
|
||||
[InlineData("invalid", false)]
|
||||
[InlineData("missing@domain", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsValid_ShouldReturnExpectedResult(string? email, bool expected)
|
||||
{
|
||||
// Act
|
||||
var result = Email.IsValid(email);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidEmailTestData))]
|
||||
public void Create_WithValidEmail_ShouldSucceed(string email)
|
||||
{
|
||||
// Act
|
||||
var result = Email.Create(email);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Value.Should().Be(email.ToLowerInvariant());
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ValidEmailTestData()
|
||||
{
|
||||
yield return new object[] { "user@example.com" };
|
||||
yield return new object[] { "USER@EXAMPLE.COM" };
|
||||
yield return new object[] { "user.name+tag@example.co.uk" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests with WebApplicationFactory
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Api.Tests.Controllers;
|
||||
|
||||
public class UsersControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public UsersControllerTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace services with test doubles
|
||||
services.RemoveAll<IUserRepository>();
|
||||
services.AddScoped<IUserRepository, InMemoryUserRepository>();
|
||||
});
|
||||
});
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateUser_WithValidRequest_ReturnsCreated()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateUserRequest
|
||||
{
|
||||
Email = "new@example.com",
|
||||
Name = "New User",
|
||||
Password = "SecurePass123!"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/users", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var user = await response.Content.ReadFromJsonAsync<UserDto>();
|
||||
user.Should().NotBeNull();
|
||||
user!.Email.Should().Be(request.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUser_WhenNotFound_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/users/{Guid.NewGuid()}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Models
|
||||
|
||||
### Entity with Value Objects
|
||||
```csharp
|
||||
namespace Domain.Entities;
|
||||
|
||||
public class User : BaseEntity
|
||||
{
|
||||
private User() { } // EF Core constructor
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
public Email Email { get; private set; } = null!;
|
||||
public string Name { get; private set; } = null!;
|
||||
public Role Role { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public DateTime UpdatedAt { get; private set; }
|
||||
|
||||
public static Result<User> Create(string email, string name)
|
||||
{
|
||||
var emailResult = Email.Create(email);
|
||||
if (emailResult.IsFailure)
|
||||
{
|
||||
return Result.Failure<User>(emailResult.Error);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return Result.Failure<User>(DomainErrors.User.NameRequired);
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = emailResult.Value,
|
||||
Name = name.Trim(),
|
||||
Role = Role.User,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
user.RaiseDomainEvent(new UserCreatedEvent(user.Id, user.Email.Value));
|
||||
|
||||
return Result.Success(user);
|
||||
}
|
||||
|
||||
public Result UpdateName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return Result.Failure(DomainErrors.User.NameRequired);
|
||||
}
|
||||
|
||||
Name = name.Trim();
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
public void PromoteToAdmin()
|
||||
{
|
||||
Role = Role.Admin;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
RaiseDomainEvent(new UserPromotedEvent(Id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Value Object
|
||||
```csharp
|
||||
namespace Domain.ValueObjects;
|
||||
|
||||
public sealed class Email : ValueObject
|
||||
{
|
||||
private static readonly Regex EmailRegex = new(
|
||||
@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
private Email(string value) => Value = value;
|
||||
|
||||
public static Result<Email> Create(string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return Result.Failure<Email>(DomainErrors.Email.Empty);
|
||||
}
|
||||
|
||||
var normalizedEmail = email.Trim().ToLowerInvariant();
|
||||
|
||||
if (!EmailRegex.IsMatch(normalizedEmail))
|
||||
{
|
||||
return Result.Failure<Email>(DomainErrors.Email.InvalidFormat);
|
||||
}
|
||||
|
||||
return Result.Success(new Email(normalizedEmail));
|
||||
}
|
||||
|
||||
public static bool IsValid(string? email) =>
|
||||
!string.IsNullOrWhiteSpace(email) && EmailRegex.IsMatch(email);
|
||||
|
||||
protected override IEnumerable<object> GetEqualityComponents()
|
||||
{
|
||||
yield return Value;
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static implicit operator string(Email email) => email.Value;
|
||||
}
|
||||
```
|
||||
|
||||
### Result Pattern
|
||||
```csharp
|
||||
namespace Domain.Common;
|
||||
|
||||
public class Result
|
||||
{
|
||||
protected Result(bool isSuccess, Error error)
|
||||
{
|
||||
if (isSuccess && error != Error.None ||
|
||||
!isSuccess && error == Error.None)
|
||||
{
|
||||
throw new ArgumentException("Invalid error", nameof(error));
|
||||
}
|
||||
|
||||
IsSuccess = isSuccess;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool IsSuccess { get; }
|
||||
public bool IsFailure => !IsSuccess;
|
||||
public Error Error { get; }
|
||||
|
||||
public static Result Success() => new(true, Error.None);
|
||||
public static Result Failure(Error error) => new(false, error);
|
||||
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
|
||||
public static Result<T> Failure<T>(Error error) => new(default!, false, error);
|
||||
}
|
||||
|
||||
public class Result<T> : Result
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
protected internal Result(T value, bool isSuccess, Error error)
|
||||
: base(isSuccess, error)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T Value => IsSuccess
|
||||
? _value
|
||||
: throw new InvalidOperationException("Cannot access value of failed result");
|
||||
}
|
||||
|
||||
public record Error(string Code, string Message)
|
||||
{
|
||||
public static readonly Error None = new(string.Empty, string.Empty);
|
||||
}
|
||||
```
|
||||
|
||||
## CQRS with MediatR
|
||||
|
||||
### Command
|
||||
```csharp
|
||||
namespace Application.Features.Users.Commands;
|
||||
|
||||
public record CreateUserCommand : IRequest<Result<UserDto>>
|
||||
{
|
||||
public required string Email { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
|
||||
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
||||
{
|
||||
public CreateUserCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty()
|
||||
.EmailAddress()
|
||||
.MaximumLength(255);
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(100);
|
||||
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty()
|
||||
.MinimumLength(8)
|
||||
.Matches("[A-Z]").WithMessage("Password must contain uppercase letter")
|
||||
.Matches("[a-z]").WithMessage("Password must contain lowercase letter")
|
||||
.Matches("[0-9]").WithMessage("Password must contain digit")
|
||||
.Matches("[^a-zA-Z0-9]").WithMessage("Password must contain special character");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, Result<UserDto>>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
|
||||
public CreateUserCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPasswordHasher passwordHasher)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_passwordHasher = passwordHasher;
|
||||
}
|
||||
|
||||
public async Task<Result<UserDto>> Handle(
|
||||
CreateUserCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _userRepository.ExistsByEmailAsync(request.Email, cancellationToken))
|
||||
{
|
||||
return Result.Failure<UserDto>(DomainErrors.User.DuplicateEmail);
|
||||
}
|
||||
|
||||
var userResult = User.Create(request.Email, request.Name);
|
||||
if (userResult.IsFailure)
|
||||
{
|
||||
return Result.Failure<UserDto>(userResult.Error);
|
||||
}
|
||||
|
||||
var user = userResult.Value;
|
||||
|
||||
await _userRepository.AddAsync(user, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(user.ToDto());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Query
|
||||
```csharp
|
||||
namespace Application.Features.Users.Queries;
|
||||
|
||||
public record GetUserByIdQuery(Guid Id) : IRequest<Result<UserDto>>;
|
||||
|
||||
public class GetUserByIdQueryHandler : IRequestHandler<GetUserByIdQuery, Result<UserDto>>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetUserByIdQueryHandler(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<UserDto>> Handle(
|
||||
GetUserByIdQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.Id, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Result.Failure<UserDto>(DomainErrors.User.NotFound);
|
||||
}
|
||||
|
||||
return Result.Success(user.ToDto());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Controllers
|
||||
|
||||
```csharp
|
||||
namespace Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
|
||||
public UsersController(ISender sender)
|
||||
{
|
||||
_sender = sender;
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetUser(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _sender.Send(new GetUserByIdQuery(id), cancellationToken);
|
||||
|
||||
return result.IsSuccess
|
||||
? Ok(result.Value)
|
||||
: NotFound(CreateProblemDetails(result.Error));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(UserDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> CreateUser(
|
||||
[FromBody] CreateUserRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new CreateUserCommand
|
||||
{
|
||||
Email = request.Email,
|
||||
Name = request.Name,
|
||||
Password = request.Password
|
||||
};
|
||||
|
||||
var result = await _sender.Send(command, cancellationToken);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return result.Error.Code == "User.DuplicateEmail"
|
||||
? Conflict(CreateProblemDetails(result.Error))
|
||||
: BadRequest(CreateProblemDetails(result.Error));
|
||||
}
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetUser),
|
||||
new { id = result.Value.Id },
|
||||
result.Value);
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblemDetails(Error error) =>
|
||||
new()
|
||||
{
|
||||
Title = error.Code,
|
||||
Detail = error.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Build and test
|
||||
dotnet build
|
||||
dotnet test
|
||||
dotnet test --collect:"XPlat Code Coverage"
|
||||
|
||||
# Run specific test project
|
||||
dotnet test tests/Application.Tests
|
||||
|
||||
# Run with filter
|
||||
dotnet test --filter "FullyQualifiedName~UserService"
|
||||
|
||||
# Generate coverage report (requires reportgenerator)
|
||||
dotnet tool install -g dotnet-reportgenerator-globaltool
|
||||
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coveragereport
|
||||
|
||||
# Run application
|
||||
dotnet run --project src/Api
|
||||
|
||||
# EF Core migrations
|
||||
dotnet ef migrations add InitialCreate --project src/Infrastructure --startup-project src/Api
|
||||
dotnet ef database update --project src/Infrastructure --startup-project src/Api
|
||||
```
|
||||
|
||||
## NuGet Dependencies
|
||||
|
||||
```xml
|
||||
<!-- Domain.csproj - Minimal dependencies -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentResults" Version="3.15.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Application.csproj -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.9.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Infrastructure.csproj -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Test projects -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.6.6" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
```
|
||||
686
.claude/skills/languages/go/SKILL.md
Normal file
686
.claude/skills/languages/go/SKILL.md
Normal file
@@ -0,0 +1,686 @@
|
||||
---
|
||||
name: go-development
|
||||
description: Go development patterns with testing, error handling, and idiomatic practices. Use when writing Go code.
|
||||
---
|
||||
|
||||
# Go Development Skill
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Application entry point
|
||||
├── internal/
|
||||
│ ├── domain/ # Business logic
|
||||
│ │ ├── user.go
|
||||
│ │ └── user_test.go
|
||||
│ ├── repository/ # Data access
|
||||
│ │ ├── user_repo.go
|
||||
│ │ └── user_repo_test.go
|
||||
│ ├── service/ # Application services
|
||||
│ │ ├── user_service.go
|
||||
│ │ └── user_service_test.go
|
||||
│ └── handler/ # HTTP handlers
|
||||
│ ├── user_handler.go
|
||||
│ └── user_handler_test.go
|
||||
├── pkg/ # Public packages
|
||||
│ └── validation/
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Table-Driven Tests
|
||||
```go
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid email",
|
||||
email: "user@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing @ symbol",
|
||||
email: "userexample.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty email",
|
||||
email: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing domain",
|
||||
email: "user@",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateEmail(tt.email)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v",
|
||||
tt.email, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Fixtures with Helper Functions
|
||||
```go
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"myapp/internal/domain"
|
||||
)
|
||||
|
||||
// Test helper - creates valid user with optional overrides
|
||||
func newTestUser(t *testing.T, opts ...func(*domain.User)) *domain.User {
|
||||
t.Helper()
|
||||
|
||||
user := &domain.User{
|
||||
ID: "user-123",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: domain.RoleUser,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(user)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// Option functions for customization
|
||||
func withEmail(email string) func(*domain.User) {
|
||||
return func(u *domain.User) {
|
||||
u.Email = email
|
||||
}
|
||||
}
|
||||
|
||||
func withRole(role domain.Role) func(*domain.User) {
|
||||
return func(u *domain.User) {
|
||||
u.Role = role
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserService_CreateUser(t *testing.T) {
|
||||
t.Run("creates user with valid data", func(t *testing.T) {
|
||||
user := newTestUser(t)
|
||||
|
||||
svc := NewUserService(mockRepo)
|
||||
result, err := svc.CreateUser(user)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.ID == "" {
|
||||
t.Error("expected user to have an ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects user with invalid email", func(t *testing.T) {
|
||||
user := newTestUser(t, withEmail("invalid-email"))
|
||||
|
||||
svc := NewUserService(mockRepo)
|
||||
_, err := svc.CreateUser(user)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid email")
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Mocking with Interfaces
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"myapp/internal/domain"
|
||||
)
|
||||
|
||||
// Repository interface for dependency injection
|
||||
type UserRepository interface {
|
||||
GetByID(ctx context.Context, id string) (*domain.User, error)
|
||||
Create(ctx context.Context, user *domain.User) error
|
||||
Update(ctx context.Context, user *domain.User) error
|
||||
}
|
||||
|
||||
// Service with injected dependencies
|
||||
type UserService struct {
|
||||
repo UserRepository
|
||||
}
|
||||
|
||||
func NewUserService(repo UserRepository) *UserService {
|
||||
return &UserService{repo: repo}
|
||||
}
|
||||
|
||||
// Test file
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"myapp/internal/domain"
|
||||
"myapp/internal/service"
|
||||
)
|
||||
|
||||
// Mock implementation
|
||||
type mockUserRepo struct {
|
||||
users map[string]*domain.User
|
||||
err error
|
||||
}
|
||||
|
||||
func newMockUserRepo() *mockUserRepo {
|
||||
return &mockUserRepo{
|
||||
users: make(map[string]*domain.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
user, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Create(ctx context.Context, user *domain.User) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Update(ctx context.Context, user *domain.User) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Types
|
||||
```go
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Sentinel errors for comparison
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
// Structured error with context
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// Error wrapping
|
||||
func GetUser(ctx context.Context, id string) (*User, error) {
|
||||
user, err := repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting user %s: %w", id, err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Error checking
|
||||
func handleError(err error) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Handle not found
|
||||
}
|
||||
|
||||
var validationErr *ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
// Handle validation error
|
||||
fmt.Printf("Field %s: %s\n", validationErr.Field, validationErr.Message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Result Pattern (Optional)
|
||||
```go
|
||||
package result
|
||||
|
||||
// Result type for explicit error handling
|
||||
type Result[T any] struct {
|
||||
value T
|
||||
err error
|
||||
}
|
||||
|
||||
func Ok[T any](value T) Result[T] {
|
||||
return Result[T]{value: value}
|
||||
}
|
||||
|
||||
func Err[T any](err error) Result[T] {
|
||||
return Result[T]{err: err}
|
||||
}
|
||||
|
||||
func (r Result[T]) IsOk() bool {
|
||||
return r.err == nil
|
||||
}
|
||||
|
||||
func (r Result[T]) IsErr() bool {
|
||||
return r.err != nil
|
||||
}
|
||||
|
||||
func (r Result[T]) Unwrap() (T, error) {
|
||||
return r.value, r.err
|
||||
}
|
||||
|
||||
func (r Result[T]) UnwrapOr(defaultValue T) T {
|
||||
if r.err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return r.value
|
||||
}
|
||||
|
||||
// Usage
|
||||
func ProcessPayment(amount float64) Result[*Receipt] {
|
||||
if amount <= 0 {
|
||||
return Err[*Receipt](ErrInvalidInput)
|
||||
}
|
||||
|
||||
receipt := &Receipt{Amount: amount}
|
||||
return Ok(receipt)
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Handlers
|
||||
|
||||
### Chi Router Pattern
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"myapp/internal/service"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
func NewUserHandler(userService *service.UserService) *UserHandler {
|
||||
return &UserHandler{userService: userService}
|
||||
}
|
||||
|
||||
func (h *UserHandler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Get("/", h.ListUsers)
|
||||
r.Post("/", h.CreateUser)
|
||||
r.Get("/{userID}", h.GetUser)
|
||||
r.Put("/{userID}", h.UpdateUser)
|
||||
r.Delete("/{userID}", h.DeleteUser)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
user, err := h.userService.GetUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.CreateUser(r.Context(), req.ToUser())
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
```
|
||||
|
||||
### Request Validation
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (r *CreateUserRequest) Validate() error {
|
||||
if r.Email == "" {
|
||||
return &domain.ValidationError{
|
||||
Field: "email",
|
||||
Message: "email is required",
|
||||
}
|
||||
}
|
||||
|
||||
if !emailRegex.MatchString(r.Email) {
|
||||
return &domain.ValidationError{
|
||||
Field: "email",
|
||||
Message: "invalid email format",
|
||||
}
|
||||
}
|
||||
|
||||
if r.Name == "" {
|
||||
return &domain.ValidationError{
|
||||
Field: "name",
|
||||
Message: "name is required",
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Name) > 100 {
|
||||
return &domain.ValidationError{
|
||||
Field: "name",
|
||||
Message: "name must be 100 characters or less",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CreateUserRequest) ToUser() *domain.User {
|
||||
return &domain.User{
|
||||
Email: r.Email,
|
||||
Name: r.Name,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Context Usage
|
||||
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Context with timeout
|
||||
func (s *UserService) ProcessOrder(ctx context.Context, orderID string) error {
|
||||
// Create a timeout context
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check context before expensive operations
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Process order...
|
||||
return s.repo.UpdateOrder(ctx, orderID)
|
||||
}
|
||||
|
||||
// Context values for request-scoped data
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
userIDKey contextKey = "userID"
|
||||
requestIDKey contextKey = "requestID"
|
||||
)
|
||||
|
||||
func WithUserID(ctx context.Context, userID string) context.Context {
|
||||
return context.WithValue(ctx, userIDKey, userID)
|
||||
}
|
||||
|
||||
func UserIDFromContext(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value(userIDKey).(string)
|
||||
return userID, ok
|
||||
}
|
||||
```
|
||||
|
||||
## Concurrency Patterns
|
||||
|
||||
### Worker Pool
|
||||
```go
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Job func(ctx context.Context) error
|
||||
|
||||
type Pool struct {
|
||||
workers int
|
||||
jobs chan Job
|
||||
results chan error
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewPool(workers int) *Pool {
|
||||
return &Pool{
|
||||
workers: workers,
|
||||
jobs: make(chan Job, workers*2),
|
||||
results: make(chan error, workers*2),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) Start(ctx context.Context) {
|
||||
for i := 0; i < p.workers; i++ {
|
||||
p.wg.Add(1)
|
||||
go p.worker(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) worker(ctx context.Context) {
|
||||
defer p.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case job, ok := <-p.jobs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := job(ctx); err != nil {
|
||||
p.results <- err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) Submit(job Job) {
|
||||
p.jobs <- job
|
||||
}
|
||||
|
||||
func (p *Pool) Close() {
|
||||
close(p.jobs)
|
||||
p.wg.Wait()
|
||||
close(p.results)
|
||||
}
|
||||
```
|
||||
|
||||
### Error Group
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func (s *OrderService) ProcessBatch(ctx context.Context, orderIDs []string) error {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
// Limit concurrency
|
||||
g.SetLimit(10)
|
||||
|
||||
for _, orderID := range orderIDs {
|
||||
orderID := orderID // Capture for goroutine
|
||||
|
||||
g.Go(func() error {
|
||||
return s.ProcessOrder(ctx, orderID)
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Testing
|
||||
go test ./... # Run all tests
|
||||
go test -v ./... # Verbose output
|
||||
go test -race ./... # Race detection
|
||||
go test -cover ./... # Coverage summary
|
||||
go test -coverprofile=coverage.out ./... # Coverage file
|
||||
go tool cover -html=coverage.out # HTML coverage report
|
||||
|
||||
# Linting
|
||||
go vet ./... # Built-in checks
|
||||
golangci-lint run # Comprehensive linting
|
||||
|
||||
# Building
|
||||
go build -o bin/server ./cmd/server # Build binary
|
||||
go build -ldflags="-s -w" -o bin/server ./cmd/server # Smaller binary
|
||||
|
||||
# Dependencies
|
||||
go mod tidy # Clean up dependencies
|
||||
go mod verify # Verify dependencies
|
||||
|
||||
# Code generation
|
||||
go generate ./... # Run generate directives
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
```go
|
||||
// ❌ BAD - Returning nil error with nil value
|
||||
func GetUser(id string) (*User, error) {
|
||||
user := db.Find(id)
|
||||
return user, nil // user might be nil!
|
||||
}
|
||||
|
||||
// ✅ GOOD - Explicit error for not found
|
||||
func GetUser(id string) (*User, error) {
|
||||
user := db.Find(id)
|
||||
if user == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ❌ BAD - Ignoring errors
|
||||
result, _ := SomeFunction()
|
||||
|
||||
// ✅ GOOD - Handle or propagate errors
|
||||
result, err := SomeFunction()
|
||||
if err != nil {
|
||||
return fmt.Errorf("calling SomeFunction: %w", err)
|
||||
}
|
||||
|
||||
// ❌ BAD - Naked goroutine without error handling
|
||||
go processItem(item)
|
||||
|
||||
// ✅ GOOD - Error handling with channels or error groups
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error {
|
||||
return processItem(ctx, item)
|
||||
})
|
||||
if err := g.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ❌ BAD - Mutex embedded in struct
|
||||
type Service struct {
|
||||
sync.Mutex
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
// ✅ GOOD - Private mutex
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
}
|
||||
```
|
||||
675
.claude/skills/languages/java/SKILL.md
Normal file
675
.claude/skills/languages/java/SKILL.md
Normal file
@@ -0,0 +1,675 @@
|
||||
---
|
||||
name: java-development
|
||||
description: Java development patterns with Spring Boot, JUnit 5, and modern Java practices. Use when writing Java code.
|
||||
---
|
||||
|
||||
# Java Development Skill
|
||||
|
||||
## Project Structure (Spring Boot)
|
||||
|
||||
```
|
||||
project/
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── java/
|
||||
│ │ │ └── com/mycompany/myapp/
|
||||
│ │ │ ├── MyApplication.java
|
||||
│ │ │ ├── domain/
|
||||
│ │ │ │ ├── User.java
|
||||
│ │ │ │ └── Order.java
|
||||
│ │ │ ├── repository/
|
||||
│ │ │ │ └── UserRepository.java
|
||||
│ │ │ ├── service/
|
||||
│ │ │ │ └── UserService.java
|
||||
│ │ │ ├── controller/
|
||||
│ │ │ │ └── UserController.java
|
||||
│ │ │ └── config/
|
||||
│ │ │ └── SecurityConfig.java
|
||||
│ │ └── resources/
|
||||
│ │ ├── application.yml
|
||||
│ │ └── application-test.yml
|
||||
│ └── test/
|
||||
│ └── java/
|
||||
│ └── com/mycompany/myapp/
|
||||
│ ├── service/
|
||||
│ │ └── UserServiceTest.java
|
||||
│ └── controller/
|
||||
│ └── UserControllerTest.java
|
||||
├── pom.xml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Testing with JUnit 5
|
||||
|
||||
### Unit Tests with Mockito
|
||||
```java
|
||||
package com.mycompany.myapp.service;
|
||||
|
||||
import com.mycompany.myapp.domain.User;
|
||||
import com.mycompany.myapp.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private UserService userService;
|
||||
|
||||
@Nested
|
||||
@DisplayName("createUser")
|
||||
class CreateUser {
|
||||
|
||||
@Test
|
||||
@DisplayName("should create user with valid data")
|
||||
void shouldCreateUserWithValidData() {
|
||||
// Given
|
||||
var request = UserFixtures.createUserRequest();
|
||||
var savedUser = UserFixtures.user();
|
||||
when(userRepository.save(any(User.class))).thenReturn(savedUser);
|
||||
|
||||
// When
|
||||
var result = userService.createUser(request);
|
||||
|
||||
// Then
|
||||
assertThat(result.getId()).isNotNull();
|
||||
assertThat(result.getEmail()).isEqualTo(request.getEmail());
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should throw exception for duplicate email")
|
||||
void shouldThrowExceptionForDuplicateEmail() {
|
||||
// Given
|
||||
var request = UserFixtures.createUserRequest();
|
||||
when(userRepository.existsByEmail(request.getEmail())).thenReturn(true);
|
||||
|
||||
// When/Then
|
||||
assertThatThrownBy(() -> userService.createUser(request))
|
||||
.isInstanceOf(DuplicateEmailException.class)
|
||||
.hasMessageContaining(request.getEmail());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getUserById")
|
||||
class GetUserById {
|
||||
|
||||
@Test
|
||||
@DisplayName("should return user when found")
|
||||
void shouldReturnUserWhenFound() {
|
||||
// Given
|
||||
var user = UserFixtures.user();
|
||||
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
|
||||
|
||||
// When
|
||||
var result = userService.getUserById(user.getId());
|
||||
|
||||
// Then
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().getId()).isEqualTo(user.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should return empty when user not found")
|
||||
void shouldReturnEmptyWhenNotFound() {
|
||||
// Given
|
||||
when(userRepository.findById(any())).thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
var result = userService.getUserById("unknown-id");
|
||||
|
||||
// Then
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Fixtures
|
||||
```java
|
||||
package com.mycompany.myapp.fixtures;
|
||||
|
||||
import com.mycompany.myapp.domain.User;
|
||||
import com.mycompany.myapp.dto.CreateUserRequest;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class UserFixtures {
|
||||
|
||||
private UserFixtures() {}
|
||||
|
||||
public static User user() {
|
||||
return user(builder -> {});
|
||||
}
|
||||
|
||||
public static User user(UserCustomizer customizer) {
|
||||
var builder = User.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.email("test@example.com")
|
||||
.name("Test User")
|
||||
.role(Role.USER)
|
||||
.createdAt(Instant.now())
|
||||
.updatedAt(Instant.now());
|
||||
|
||||
customizer.customize(builder);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static CreateUserRequest createUserRequest() {
|
||||
return createUserRequest(builder -> {});
|
||||
}
|
||||
|
||||
public static CreateUserRequest createUserRequest(CreateUserRequestCustomizer customizer) {
|
||||
var builder = CreateUserRequest.builder()
|
||||
.email("newuser@example.com")
|
||||
.name("New User")
|
||||
.password("SecurePass123!");
|
||||
|
||||
customizer.customize(builder);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface UserCustomizer {
|
||||
void customize(User.UserBuilder builder);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface CreateUserRequestCustomizer {
|
||||
void customize(CreateUserRequest.CreateUserRequestBuilder builder);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in tests
|
||||
var adminUser = UserFixtures.user(builder -> builder.role(Role.ADMIN));
|
||||
var customRequest = UserFixtures.createUserRequest(builder ->
|
||||
builder.email("custom@example.com"));
|
||||
```
|
||||
|
||||
### Parameterized Tests
|
||||
```java
|
||||
package com.mycompany.myapp.validation;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class EmailValidatorTest {
|
||||
|
||||
private final EmailValidator validator = new EmailValidator();
|
||||
|
||||
@ParameterizedTest
|
||||
@DisplayName("should accept valid emails")
|
||||
@ValueSource(strings = {
|
||||
"user@example.com",
|
||||
"user.name@example.com",
|
||||
"user+tag@example.co.uk"
|
||||
})
|
||||
void shouldAcceptValidEmails(String email) {
|
||||
assertThat(validator.isValid(email)).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@DisplayName("should reject invalid emails")
|
||||
@ValueSource(strings = {
|
||||
"invalid",
|
||||
"missing@domain",
|
||||
"@nodomain.com",
|
||||
"spaces in@email.com"
|
||||
})
|
||||
void shouldRejectInvalidEmails(String email) {
|
||||
assertThat(validator.isValid(email)).isFalse();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@DisplayName("should reject null and empty emails")
|
||||
@NullAndEmptySource
|
||||
void shouldRejectNullAndEmpty(String email) {
|
||||
assertThat(validator.isValid(email)).isFalse();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@DisplayName("should validate password strength")
|
||||
@CsvSource({
|
||||
"password,false",
|
||||
"Password1,false",
|
||||
"Password1!,true",
|
||||
"P@ssw0rd,true"
|
||||
})
|
||||
void shouldValidatePasswordStrength(String password, boolean expected) {
|
||||
assertThat(validator.isStrongPassword(password)).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests with Spring
|
||||
```java
|
||||
package com.mycompany.myapp.controller;
|
||||
|
||||
import com.mycompany.myapp.fixtures.UserFixtures;
|
||||
import com.mycompany.myapp.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class UserControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/users creates user and returns 201")
|
||||
void createUserReturnsCreated() throws Exception {
|
||||
var request = """
|
||||
{
|
||||
"email": "new@example.com",
|
||||
"name": "New User",
|
||||
"password": "SecurePass123!"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(request))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNotEmpty())
|
||||
.andExpect(jsonPath("$.email").value("new@example.com"))
|
||||
.andExpect(jsonPath("$.name").value("New User"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/users/{id} returns user when found")
|
||||
void getUserReturnsUserWhenFound() throws Exception {
|
||||
var user = userRepository.save(UserFixtures.user());
|
||||
|
||||
mockMvc.perform(get("/api/users/{id}", user.getId()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(user.getId()))
|
||||
.andExpect(jsonPath("$.email").value(user.getEmail()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/users/{id} returns 404 when not found")
|
||||
void getUserReturns404WhenNotFound() throws Exception {
|
||||
mockMvc.perform(get("/api/users/{id}", "unknown-id"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Models with Records
|
||||
|
||||
```java
|
||||
package com.mycompany.myapp.domain;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.time.Instant;
|
||||
|
||||
// Immutable domain model using records (Java 17+)
|
||||
public record User(
|
||||
String id,
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(max = 100) String name,
|
||||
Role role,
|
||||
Instant createdAt,
|
||||
Instant updatedAt
|
||||
) {
|
||||
// Compact constructor for validation
|
||||
public User {
|
||||
if (email != null) {
|
||||
email = email.toLowerCase().trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Static factory methods
|
||||
public static User create(String email, String name) {
|
||||
var now = Instant.now();
|
||||
return new User(
|
||||
UUID.randomUUID().toString(),
|
||||
email,
|
||||
name,
|
||||
Role.USER,
|
||||
now,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
// Wither methods for immutable updates
|
||||
public User withName(String newName) {
|
||||
return new User(id, email, newName, role, createdAt, Instant.now());
|
||||
}
|
||||
|
||||
public User withRole(Role newRole) {
|
||||
return new User(id, email, name, newRole, createdAt, Instant.now());
|
||||
}
|
||||
}
|
||||
|
||||
// For mutable entities (JPA)
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private String id;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Role role;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
updatedAt = createdAt;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Service Layer
|
||||
|
||||
```java
|
||||
package com.mycompany.myapp.service;
|
||||
|
||||
import com.mycompany.myapp.domain.User;
|
||||
import com.mycompany.myapp.dto.CreateUserRequest;
|
||||
import com.mycompany.myapp.exception.DuplicateEmailException;
|
||||
import com.mycompany.myapp.exception.UserNotFoundException;
|
||||
import com.mycompany.myapp.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Transactional
|
||||
public User createUser(CreateUserRequest request) {
|
||||
if (userRepository.existsByEmail(request.getEmail())) {
|
||||
throw new DuplicateEmailException(request.getEmail());
|
||||
}
|
||||
|
||||
var user = User.create(
|
||||
request.getEmail(),
|
||||
request.getName()
|
||||
);
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Optional<User> getUserById(String id) {
|
||||
return userRepository.findById(id);
|
||||
}
|
||||
|
||||
public User getUserByIdOrThrow(String id) {
|
||||
return userRepository.findById(id)
|
||||
.orElseThrow(() -> new UserNotFoundException(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public User updateUser(String id, UpdateUserRequest request) {
|
||||
var user = getUserByIdOrThrow(id);
|
||||
|
||||
var updatedUser = user
|
||||
.withName(request.getName())
|
||||
.withRole(request.getRole());
|
||||
|
||||
return userRepository.save(updatedUser);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(String id) {
|
||||
if (!userRepository.existsById(id)) {
|
||||
throw new UserNotFoundException(id);
|
||||
}
|
||||
userRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## REST Controllers
|
||||
|
||||
```java
|
||||
package com.mycompany.myapp.controller;
|
||||
|
||||
import com.mycompany.myapp.dto.*;
|
||||
import com.mycompany.myapp.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
@GetMapping
|
||||
public List<UserResponse> listUsers() {
|
||||
return userService.getAllUsers().stream()
|
||||
.map(userMapper::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getUser(@PathVariable String id) {
|
||||
return userService.getUserById(id)
|
||||
.map(userMapper::toResponse)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||
var user = userService.createUser(request);
|
||||
return userMapper.toResponse(user);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public UserResponse updateUser(
|
||||
@PathVariable String id,
|
||||
@Valid @RequestBody UpdateUserRequest request) {
|
||||
var user = userService.updateUser(id, request);
|
||||
return userMapper.toResponse(user);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteUser(@PathVariable String id) {
|
||||
userService.deleteUser(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exception Handling
|
||||
|
||||
```java
|
||||
package com.mycompany.myapp.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(UserNotFoundException.class)
|
||||
public ProblemDetail handleUserNotFound(UserNotFoundException ex) {
|
||||
var problem = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.NOT_FOUND,
|
||||
ex.getMessage()
|
||||
);
|
||||
problem.setTitle("User Not Found");
|
||||
problem.setType(URI.create("https://api.myapp.com/errors/user-not-found"));
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(DuplicateEmailException.class)
|
||||
public ProblemDetail handleDuplicateEmail(DuplicateEmailException ex) {
|
||||
var problem = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.CONFLICT,
|
||||
ex.getMessage()
|
||||
);
|
||||
problem.setTitle("Duplicate Email");
|
||||
problem.setType(URI.create("https://api.myapp.com/errors/duplicate-email"));
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
|
||||
var problem = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Validation failed"
|
||||
);
|
||||
problem.setTitle("Validation Error");
|
||||
|
||||
var errors = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> new ValidationError(e.getField(), e.getDefaultMessage()))
|
||||
.toList();
|
||||
|
||||
problem.setProperty("errors", errors);
|
||||
return problem;
|
||||
}
|
||||
|
||||
record ValidationError(String field, String message) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Maven
|
||||
mvn clean install # Build and test
|
||||
mvn test # Run tests only
|
||||
mvn verify # Run integration tests
|
||||
mvn spring-boot:run # Run application
|
||||
mvn jacoco:report # Generate coverage report
|
||||
|
||||
# Gradle
|
||||
./gradlew build # Build and test
|
||||
./gradlew test # Run tests only
|
||||
./gradlew integrationTest # Run integration tests
|
||||
./gradlew bootRun # Run application
|
||||
./gradlew jacocoTestReport # Generate coverage report
|
||||
|
||||
# Common flags
|
||||
-DskipTests # Skip tests
|
||||
-Dspring.profiles.active=test # Set profile
|
||||
```
|
||||
|
||||
## Dependencies (pom.xml)
|
||||
|
||||
```xml
|
||||
<dependencies>
|
||||
<!-- Spring Boot -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
446
.claude/skills/languages/python/SKILL.md
Normal file
446
.claude/skills/languages/python/SKILL.md
Normal file
@@ -0,0 +1,446 @@
|
||||
---
|
||||
name: python-fastapi
|
||||
description: Python development with FastAPI, pytest, Ruff, Pydantic v2, and SQLAlchemy async patterns. Use when writing Python code, tests, or APIs.
|
||||
---
|
||||
|
||||
# Python Development Skill
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── backend/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app entry
|
||||
│ ├── config.py # Pydantic Settings
|
||||
│ ├── domains/
|
||||
│ │ └── users/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # API routes
|
||||
│ │ ├── service.py # Business logic
|
||||
│ │ ├── models.py # SQLAlchemy models
|
||||
│ │ └── schemas.py # Pydantic schemas
|
||||
│ └── shared/
|
||||
│ ├── database.py # DB session management
|
||||
│ └── models/ # Base models
|
||||
tests/
|
||||
├── backend/
|
||||
│ ├── conftest.py # Shared fixtures
|
||||
│ └── domains/
|
||||
│ └── users/
|
||||
│ └── test_service.py
|
||||
```
|
||||
|
||||
## Testing with pytest
|
||||
|
||||
### Configuration (pyproject.toml)
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = [
|
||||
"-v",
|
||||
"--strict-markers",
|
||||
"--cov=src",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=80"
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["src"]
|
||||
omit = ["*/__init__.py", "*/migrations/*"]
|
||||
```
|
||||
|
||||
### Async Test Fixtures
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.backend.shared.models.base import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session():
|
||||
"""Create in-memory SQLite for fast tests."""
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async_session = sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
await engine.dispose()
|
||||
```
|
||||
|
||||
### Factory Functions for Test Data
|
||||
```python
|
||||
# tests/factories.py
|
||||
from typing import Any
|
||||
|
||||
from src.backend.domains.users.schemas import UserCreate
|
||||
|
||||
|
||||
def get_mock_user_create(overrides: dict[str, Any] | None = None) -> UserCreate:
|
||||
"""Factory for UserCreate with sensible defaults."""
|
||||
defaults = {
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"password": "securepassword123",
|
||||
}
|
||||
return UserCreate(**(defaults | (overrides or {})))
|
||||
|
||||
|
||||
def get_mock_user_dict(overrides: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Factory for raw user dict."""
|
||||
defaults = {
|
||||
"id": "user_123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"is_active": True,
|
||||
}
|
||||
return defaults | (overrides or {})
|
||||
```
|
||||
|
||||
### Test Patterns
|
||||
```python
|
||||
# tests/backend/domains/users/test_service.py
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from src.backend.domains.users.service import UserService
|
||||
from tests.factories import get_mock_user_create
|
||||
|
||||
|
||||
class TestUserService:
|
||||
"""Test user service behavior through public API."""
|
||||
|
||||
async def test_create_user_returns_user_with_id(self, db_session):
|
||||
"""Creating a user should return user with generated ID."""
|
||||
service = UserService(db_session)
|
||||
user_data = get_mock_user_create()
|
||||
|
||||
result = await service.create_user(user_data)
|
||||
|
||||
assert result.id is not None
|
||||
assert result.email == user_data.email
|
||||
|
||||
async def test_create_user_hashes_password(self, db_session):
|
||||
"""Password should be hashed, not stored in plain text."""
|
||||
service = UserService(db_session)
|
||||
user_data = get_mock_user_create({"password": "mypassword"})
|
||||
|
||||
result = await service.create_user(user_data)
|
||||
|
||||
assert result.hashed_password != "mypassword"
|
||||
assert len(result.hashed_password) > 50 # Hashed
|
||||
|
||||
async def test_create_duplicate_email_raises_error(self, db_session):
|
||||
"""Duplicate email should raise ValueError."""
|
||||
service = UserService(db_session)
|
||||
user_data = get_mock_user_create()
|
||||
await service.create_user(user_data)
|
||||
|
||||
with pytest.raises(ValueError, match="Email already exists"):
|
||||
await service.create_user(user_data)
|
||||
|
||||
async def test_get_user_not_found_returns_none(self, db_session):
|
||||
"""Non-existent user should return None."""
|
||||
service = UserService(db_session)
|
||||
|
||||
result = await service.get_user("nonexistent_id")
|
||||
|
||||
assert result is None
|
||||
```
|
||||
|
||||
## Pydantic v2 Patterns
|
||||
|
||||
### Schemas with Validation
|
||||
```python
|
||||
# src/backend/domains/users/schemas.py
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user schema with shared fields."""
|
||||
|
||||
email: EmailStr
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for creating a user."""
|
||||
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_must_be_strong(cls, v: str) -> str:
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("Password must contain uppercase letter")
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("Password must contain a digit")
|
||||
return v
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
"""Schema for user responses (no password)."""
|
||||
|
||||
id: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
```
|
||||
|
||||
### Configuration with Pydantic Settings
|
||||
```python
|
||||
# src/backend/config.py
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings from environment."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# Database
|
||||
database_url: str
|
||||
database_pool_size: int = 5
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379"
|
||||
|
||||
# Security
|
||||
secret_key: str
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
# AWS
|
||||
aws_region: str = "eu-west-2"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
```
|
||||
|
||||
## FastAPI Patterns
|
||||
|
||||
### Router with Dependency Injection
|
||||
```python
|
||||
# src/backend/domains/users/router.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.shared.database import get_db_session
|
||||
from .schemas import UserCreate, UserResponse
|
||||
from .service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
def get_user_service(
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> UserService:
|
||||
return UserService(session)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
service: UserService = Depends(get_user_service),
|
||||
) -> UserResponse:
|
||||
"""Create a new user."""
|
||||
try:
|
||||
return await service.create_user(user_data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
service: UserService = Depends(get_user_service),
|
||||
) -> UserResponse:
|
||||
"""Get user by ID."""
|
||||
user = await service.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
```
|
||||
|
||||
## SQLAlchemy 2.0 Async Patterns
|
||||
|
||||
### Model Definition
|
||||
```python
|
||||
# src/backend/domains/users/models.py
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.shared.models.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
hashed_password: Mapped[str] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow
|
||||
)
|
||||
```
|
||||
|
||||
### Async Repository Pattern
|
||||
```python
|
||||
# src/backend/domains/users/repository.py
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def get_by_id(self, user_id: str) -> User | None:
|
||||
result = await self._session.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
result = await self._session.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, user: User) -> User:
|
||||
self._session.add(user)
|
||||
await self._session.commit()
|
||||
await self._session.refresh(user)
|
||||
return user
|
||||
```
|
||||
|
||||
## Ruff Configuration
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"SIM", # flake8-simplify
|
||||
"ASYNC", # flake8-async
|
||||
]
|
||||
ignore = [
|
||||
"B008", # function-call-in-default-argument (FastAPI Depends)
|
||||
"E501", # line-too-long (handled by formatter)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401"] # unused imports (re-exports)
|
||||
"tests/*" = ["S101"] # assert allowed in tests
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Testing
|
||||
pytest # Run all tests
|
||||
pytest tests/backend/domains/users/ # Run specific directory
|
||||
pytest -k "test_create" # Run tests matching pattern
|
||||
pytest --cov=src --cov-report=html # Coverage with HTML report
|
||||
pytest -x # Stop on first failure
|
||||
pytest --lf # Run last failed tests
|
||||
|
||||
# Linting & Formatting
|
||||
ruff check . # Lint check
|
||||
ruff check . --fix # Auto-fix issues
|
||||
ruff format . # Format code
|
||||
ruff format . --check # Check formatting
|
||||
|
||||
# Type Checking
|
||||
mypy src # Full type check
|
||||
pyright src # Alternative type checker
|
||||
|
||||
# Development
|
||||
uvicorn src.backend.main:app --reload # Dev server
|
||||
alembic revision --autogenerate -m "msg" # Create migration
|
||||
alembic upgrade head # Apply migrations
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
```python
|
||||
# BAD: Mutable default argument
|
||||
def process_items(items: list = []): # Creates shared state!
|
||||
items.append("new")
|
||||
return items
|
||||
|
||||
# GOOD: Use None default
|
||||
def process_items(items: list | None = None) -> list:
|
||||
if items is None:
|
||||
items = []
|
||||
return [*items, "new"]
|
||||
|
||||
|
||||
# BAD: Bare except
|
||||
try:
|
||||
risky_operation()
|
||||
except: # Catches everything including SystemExit!
|
||||
pass
|
||||
|
||||
# GOOD: Specific exceptions
|
||||
try:
|
||||
risky_operation()
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# BAD: String formatting in SQL (injection risk!)
|
||||
query = f"SELECT * FROM users WHERE email = '{email}'"
|
||||
|
||||
# GOOD: Parameterized query
|
||||
stmt = select(User).where(User.email == email)
|
||||
|
||||
|
||||
# BAD: Sync in async context
|
||||
async def get_data():
|
||||
return requests.get(url) # Blocks event loop!
|
||||
|
||||
# GOOD: Use async client
|
||||
async def get_data():
|
||||
async with httpx.AsyncClient() as client:
|
||||
return await client.get(url)
|
||||
```
|
||||
574
.claude/skills/languages/rust/SKILL.md
Normal file
574
.claude/skills/languages/rust/SKILL.md
Normal file
@@ -0,0 +1,574 @@
|
||||
---
|
||||
name: rust-async
|
||||
description: Rust development with Tokio async runtime, cargo test, clippy, and systems programming patterns. Use when writing Rust code, agents, or system utilities.
|
||||
---
|
||||
|
||||
# Rust Development Skill
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
my-agent/
|
||||
├── Cargo.toml
|
||||
├── Cargo.lock
|
||||
├── src/
|
||||
│ ├── main.rs # Binary entry point
|
||||
│ ├── lib.rs # Library root (if dual crate)
|
||||
│ ├── config.rs # Configuration handling
|
||||
│ ├── error.rs # Error types
|
||||
│ ├── client/
|
||||
│ │ ├── mod.rs
|
||||
│ │ └── http.rs
|
||||
│ └── discovery/
|
||||
│ ├── mod.rs
|
||||
│ ├── system.rs
|
||||
│ └── network.rs
|
||||
├── tests/
|
||||
│ └── integration/
|
||||
│ └── discovery_test.rs
|
||||
└── benches/
|
||||
└── performance.rs
|
||||
```
|
||||
|
||||
## Cargo Configuration
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[package]
|
||||
name = "my-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.11", features = ["json", "socks"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# System info
|
||||
sysinfo = "0.30"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
mockall = "0.12"
|
||||
tempfile = "3.10"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
lto = true # Link-time optimization
|
||||
codegen-units = 1 # Better optimization
|
||||
strip = true # Strip symbols
|
||||
panic = "abort" # Smaller binary
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
all = "deny"
|
||||
pedantic = "warn"
|
||||
nursery = "warn"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Types
|
||||
```rust
|
||||
// src/error.rs
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AgentError {
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("Discovery failed: {message}")]
|
||||
Discovery { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> },
|
||||
|
||||
#[error("Connection timeout after {duration_secs}s")]
|
||||
Timeout { duration_secs: u64 },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AgentError>;
|
||||
```
|
||||
|
||||
### Result Pattern Usage
|
||||
```rust
|
||||
// src/client/http.rs
|
||||
use crate::error::{AgentError, Result};
|
||||
|
||||
pub struct HttpClient {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
pub fn new(base_url: &str, timeout_secs: u64) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(AgentError::Network)?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url: base_url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<T>()
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn post<T, R>(&self, path: &str, body: &T) -> Result<R>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
R: serde::de::DeserializeOwned,
|
||||
{
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<R>()
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Async Patterns with Tokio
|
||||
|
||||
### Main Entry Point
|
||||
```rust
|
||||
// src/main.rs
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod client;
|
||||
mod discovery;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "Starting agent");
|
||||
|
||||
run(config).await
|
||||
}
|
||||
|
||||
async fn run(config: Config) -> Result<()> {
|
||||
let client = client::HttpClient::new(&config.server_url, config.timeout_secs)?;
|
||||
|
||||
// Main loop with graceful shutdown
|
||||
let mut interval = tokio::time::interval(config.poll_interval);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
if let Err(e) = discovery::collect_and_send(&client).await {
|
||||
tracing::error!(error = %e, "Discovery cycle failed");
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("Shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Concurrent Operations
|
||||
```rust
|
||||
// src/discovery/mod.rs
|
||||
use tokio::task::JoinSet;
|
||||
use crate::error::Result;
|
||||
|
||||
pub async fn discover_all() -> Result<SystemInfo> {
|
||||
let mut tasks = JoinSet::new();
|
||||
|
||||
// Spawn concurrent discovery tasks
|
||||
tasks.spawn(async { ("cpu", discover_cpu().await) });
|
||||
tasks.spawn(async { ("memory", discover_memory().await) });
|
||||
tasks.spawn(async { ("disk", discover_disk().await) });
|
||||
tasks.spawn(async { ("network", discover_network().await) });
|
||||
|
||||
let mut info = SystemInfo::default();
|
||||
|
||||
// Collect results as they complete
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
match result {
|
||||
Ok((name, Ok(data))) => {
|
||||
tracing::debug!(component = name, "Discovery completed");
|
||||
info.merge(name, data);
|
||||
}
|
||||
Ok((name, Err(e))) => {
|
||||
tracing::warn!(component = name, error = %e, "Discovery failed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Task panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Unit Tests
|
||||
```rust
|
||||
// src/discovery/system.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CpuInfo {
|
||||
pub cores: usize,
|
||||
pub usage_percent: f32,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl CpuInfo {
|
||||
pub fn is_high_usage(&self) -> bool {
|
||||
self.usage_percent > 80.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn get_mock_cpu_info(overrides: Option<CpuInfo>) -> CpuInfo {
|
||||
let default = CpuInfo {
|
||||
cores: 4,
|
||||
usage_percent: 25.0,
|
||||
model: "Test CPU".to_string(),
|
||||
};
|
||||
overrides.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_high_usage_returns_true_above_threshold() {
|
||||
let cpu = get_mock_cpu_info(Some(CpuInfo {
|
||||
usage_percent: 85.0,
|
||||
..get_mock_cpu_info(None)
|
||||
}));
|
||||
|
||||
assert!(cpu.is_high_usage());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_high_usage_returns_false_below_threshold() {
|
||||
let cpu = get_mock_cpu_info(Some(CpuInfo {
|
||||
usage_percent: 50.0,
|
||||
..get_mock_cpu_info(None)
|
||||
}));
|
||||
|
||||
assert!(!cpu.is_high_usage());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_high_usage_returns_false_at_boundary() {
|
||||
let cpu = get_mock_cpu_info(Some(CpuInfo {
|
||||
usage_percent: 80.0,
|
||||
..get_mock_cpu_info(None)
|
||||
}));
|
||||
|
||||
assert!(!cpu.is_high_usage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Tests
|
||||
```rust
|
||||
// tests/integration/client_test.rs
|
||||
use tokio_test::block_on;
|
||||
use my_agent::client::HttpClient;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_handles_timeout() {
|
||||
let client = HttpClient::new("http://localhost:9999", 1).unwrap();
|
||||
|
||||
let result: Result<serde_json::Value, _> = client.get("/test").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_requests_complete() {
|
||||
let client = HttpClient::new("https://httpbin.org", 30).unwrap();
|
||||
|
||||
let handles: Vec<_> = (0..5)
|
||||
.map(|i| {
|
||||
let client = client.clone();
|
||||
tokio::spawn(async move {
|
||||
client.get::<serde_json::Value>(&format!("/delay/{}", i % 2)).await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
let result = handle.await.unwrap();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mocking with Mockall
|
||||
```rust
|
||||
// src/discovery/mod.rs
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
pub trait SystemDiscovery {
|
||||
fn get_cpu_info(&self) -> crate::error::Result<CpuInfo>;
|
||||
fn get_memory_info(&self) -> crate::error::Result<MemoryInfo>;
|
||||
}
|
||||
|
||||
// src/discovery/collector.rs
|
||||
pub struct Collector<D: SystemDiscovery> {
|
||||
discovery: D,
|
||||
}
|
||||
|
||||
impl<D: SystemDiscovery> Collector<D> {
|
||||
pub fn new(discovery: D) -> Self {
|
||||
Self { discovery }
|
||||
}
|
||||
|
||||
pub fn collect(&self) -> crate::error::Result<SystemSnapshot> {
|
||||
let cpu = self.discovery.get_cpu_info()?;
|
||||
let memory = self.discovery.get_memory_info()?;
|
||||
|
||||
Ok(SystemSnapshot { cpu, memory })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mockall::predicate::*;
|
||||
|
||||
#[test]
|
||||
fn test_collector_combines_cpu_and_memory() {
|
||||
let mut mock = MockSystemDiscovery::new();
|
||||
|
||||
mock.expect_get_cpu_info()
|
||||
.times(1)
|
||||
.returning(|| Ok(CpuInfo {
|
||||
cores: 4,
|
||||
usage_percent: 50.0,
|
||||
model: "Mock CPU".to_string(),
|
||||
}));
|
||||
|
||||
mock.expect_get_memory_info()
|
||||
.times(1)
|
||||
.returning(|| Ok(MemoryInfo {
|
||||
total_gb: 16.0,
|
||||
available_gb: 8.0,
|
||||
}));
|
||||
|
||||
let collector = Collector::new(mock);
|
||||
let snapshot = collector.collect().unwrap();
|
||||
|
||||
assert_eq!(snapshot.cpu.cores, 4);
|
||||
assert_eq!(snapshot.memory.total_gb, 16.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```rust
|
||||
// src/config.rs
|
||||
use crate::error::{AgentError, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub server_url: String,
|
||||
pub timeout_secs: u64,
|
||||
pub poll_interval: Duration,
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let server_url = std::env::var("SERVER_URL")
|
||||
.map_err(|_| AgentError::Config("SERVER_URL not set".to_string()))?;
|
||||
|
||||
let timeout_secs = std::env::var("TIMEOUT_SECS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
.map_err(|_| AgentError::Config("Invalid TIMEOUT_SECS".to_string()))?;
|
||||
|
||||
let poll_interval_secs: u64 = std::env::var("POLL_INTERVAL_SECS")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.map_err(|_| AgentError::Config("Invalid POLL_INTERVAL_SECS".to_string()))?;
|
||||
|
||||
let agent_id = std::env::var("AGENT_ID")
|
||||
.unwrap_or_else(|_| hostname::get()
|
||||
.map(|h| h.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string()));
|
||||
|
||||
Ok(Self {
|
||||
server_url,
|
||||
timeout_secs,
|
||||
poll_interval: Duration::from_secs(poll_interval_secs),
|
||||
agent_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Clippy Configuration
|
||||
|
||||
```toml
|
||||
# clippy.toml
|
||||
cognitive-complexity-threshold = 10
|
||||
too-many-arguments-threshold = 5
|
||||
type-complexity-threshold = 200
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Building
|
||||
cargo build # Debug build
|
||||
cargo build --release # Release build
|
||||
cargo build --target x86_64-unknown-linux-musl # Static binary
|
||||
|
||||
# Testing
|
||||
cargo test # Run all tests
|
||||
cargo test -- --nocapture # Show println! output
|
||||
cargo test test_name # Run specific test
|
||||
cargo test --test integration # Run integration tests only
|
||||
|
||||
# Linting
|
||||
cargo clippy # Run clippy
|
||||
cargo clippy -- -D warnings # Treat warnings as errors
|
||||
cargo clippy --fix # Auto-fix issues
|
||||
|
||||
# Formatting
|
||||
cargo fmt # Format code
|
||||
cargo fmt --check # Check formatting
|
||||
|
||||
# Other
|
||||
cargo doc --open # Generate and open docs
|
||||
cargo bench # Run benchmarks
|
||||
cargo tree # Show dependency tree
|
||||
cargo audit # Check for vulnerabilities
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
```rust
|
||||
// BAD: Unwrap without context
|
||||
let config = Config::from_env().unwrap();
|
||||
|
||||
// GOOD: Provide context or use ?
|
||||
let config = Config::from_env()
|
||||
.expect("Failed to load configuration from environment");
|
||||
|
||||
// Or in functions returning Result:
|
||||
let config = Config::from_env()?;
|
||||
|
||||
|
||||
// BAD: Clone when not needed
|
||||
fn process(data: &Vec<String>) {
|
||||
let cloned = data.clone(); // Unnecessary allocation
|
||||
for item in cloned.iter() {
|
||||
println!("{}", item);
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Use references
|
||||
fn process(data: &[String]) {
|
||||
for item in data {
|
||||
println!("{}", item);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// BAD: String concatenation in loop
|
||||
let mut result = String::new();
|
||||
for item in items {
|
||||
result = result + &item + ", "; // Allocates each iteration
|
||||
}
|
||||
|
||||
// GOOD: Use push_str or join
|
||||
let result = items.join(", ");
|
||||
|
||||
|
||||
// BAD: Blocking in async context
|
||||
async fn fetch_data() {
|
||||
std::thread::sleep(Duration::from_secs(1)); // Blocks runtime!
|
||||
}
|
||||
|
||||
// GOOD: Use async sleep
|
||||
async fn fetch_data() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
|
||||
// BAD: Ignoring errors silently
|
||||
let _ = file.write_all(data);
|
||||
|
||||
// GOOD: Handle or propagate errors
|
||||
file.write_all(data)?;
|
||||
// Or log if truly ignorable:
|
||||
if let Err(e) = file.write_all(data) {
|
||||
tracing::warn!(error = %e, "Failed to write data");
|
||||
}
|
||||
```
|
||||
624
.claude/skills/languages/typescript/SKILL.md
Normal file
624
.claude/skills/languages/typescript/SKILL.md
Normal file
@@ -0,0 +1,624 @@
|
||||
---
|
||||
name: typescript-react
|
||||
description: TypeScript development with React, Vitest, ESLint, Zod, TanStack Query, and Radix UI patterns. Use when writing TypeScript, React components, or frontend code.
|
||||
---
|
||||
|
||||
# TypeScript React Development Skill
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.tsx # App entry point
|
||||
├── App.tsx # Root component
|
||||
├── features/
|
||||
│ └── users/
|
||||
│ ├── components/
|
||||
│ │ ├── UserList.tsx
|
||||
│ │ └── UserForm.tsx
|
||||
│ ├── hooks/
|
||||
│ │ └── useUsers.ts
|
||||
│ ├── api/
|
||||
│ │ └── users.ts
|
||||
│ └── schemas/
|
||||
│ └── user.schema.ts
|
||||
├── shared/
|
||||
│ ├── components/
|
||||
│ │ └── ui/ # Radix + Tailwind components
|
||||
│ ├── hooks/
|
||||
│ └── lib/
|
||||
│ └── api-client.ts
|
||||
└── test/
|
||||
└── setup.ts # Vitest setup
|
||||
tests/
|
||||
└── features/
|
||||
└── users/
|
||||
└── UserList.test.tsx
|
||||
```
|
||||
|
||||
## Vitest Configuration
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['**/*.test.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'cobertura'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'src/test/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/index.ts',
|
||||
],
|
||||
thresholds: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Test Setup
|
||||
```typescript
|
||||
// src/test/setup.ts
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock ResizeObserver (required for Radix UI)
|
||||
class ResizeObserverMock {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
window.ResizeObserver = ResizeObserverMock;
|
||||
|
||||
// Mock scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
// Mock pointer capture (for Radix Select)
|
||||
Element.prototype.hasPointerCapture = vi.fn();
|
||||
Element.prototype.setPointerCapture = vi.fn();
|
||||
Element.prototype.releasePointerCapture = vi.fn();
|
||||
```
|
||||
|
||||
## Testing with React Testing Library
|
||||
|
||||
### Component Testing Pattern
|
||||
```typescript
|
||||
// tests/features/users/UserList.test.tsx
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { UserList } from '@/features/users/components/UserList';
|
||||
import { getMockUser } from './factories';
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('UserList', () => {
|
||||
it('should display users when data loads', async () => {
|
||||
const users = [
|
||||
getMockUser({ name: 'Alice' }),
|
||||
getMockUser({ name: 'Bob' }),
|
||||
];
|
||||
|
||||
render(<UserList users={users} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
render(<UserList users={[]} isLoading />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onDelete when delete button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDelete = vi.fn();
|
||||
const users = [getMockUser({ id: 'user-1', name: 'Alice' })];
|
||||
|
||||
render(<UserList users={users} onDelete={onDelete} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete alice/i }));
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('should filter users by search term', async () => {
|
||||
const user = userEvent.setup();
|
||||
const users = [
|
||||
getMockUser({ name: 'Alice Smith' }),
|
||||
getMockUser({ name: 'Bob Jones' }),
|
||||
];
|
||||
|
||||
render(<UserList users={users} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.type(screen.getByRole('searchbox'), 'Alice');
|
||||
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bob Jones')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Factory Functions
|
||||
```typescript
|
||||
// tests/features/users/factories.ts
|
||||
import type { User } from '@/features/users/schemas/user.schema';
|
||||
|
||||
export const getMockUser = (overrides?: Partial<User>): User => ({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const getMockUserList = (count: number): User[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
getMockUser({ id: `user-${i}`, name: `User ${i}` })
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Hooks
|
||||
```typescript
|
||||
// tests/features/users/useUsers.test.tsx
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { useUsers } from '@/features/users/hooks/useUsers';
|
||||
import * as api from '@/features/users/api/users';
|
||||
import { getMockUser } from './factories';
|
||||
|
||||
vi.mock('@/features/users/api/users');
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useUsers', () => {
|
||||
it('should return users on successful fetch', async () => {
|
||||
const mockUsers = [getMockUser()];
|
||||
vi.mocked(api.fetchUsers).mockResolvedValue(mockUsers);
|
||||
|
||||
const { result } = renderHook(() => useUsers(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual(mockUsers);
|
||||
});
|
||||
|
||||
it('should return error state on failure', async () => {
|
||||
vi.mocked(api.fetchUsers).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useUsers(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(result.current.error?.message).toBe('Network error');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Zod Schema Patterns
|
||||
|
||||
### Schema Definition
|
||||
```typescript
|
||||
// src/features/users/schemas/user.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(100),
|
||||
role: z.enum(['admin', 'user', 'guest']),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
export const userCreateSchema = userSchema.omit({ id: true, createdAt: true });
|
||||
export type UserCreate = z.infer<typeof userCreateSchema>;
|
||||
|
||||
export const userUpdateSchema = userCreateSchema.partial();
|
||||
export type UserUpdate = z.infer<typeof userUpdateSchema>;
|
||||
|
||||
// Validation at API boundary
|
||||
export const parseUser = (data: unknown): User => userSchema.parse(data);
|
||||
|
||||
export const parseUsers = (data: unknown): User[] =>
|
||||
z.array(userSchema).parse(data);
|
||||
```
|
||||
|
||||
### Form Validation with React Hook Form
|
||||
```typescript
|
||||
// src/features/users/components/UserForm.tsx
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { userCreateSchema, type UserCreate } from '../schemas/user.schema';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { Input } from '@/shared/components/ui/input';
|
||||
|
||||
type UserFormProps = {
|
||||
onSubmit: (data: UserCreate) => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function UserForm({ onSubmit, isLoading }: UserFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<UserCreate>({
|
||||
resolver: zodResolver(userCreateSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
{...register('name')}
|
||||
placeholder="Name"
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-red-500">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## TanStack Query Patterns
|
||||
|
||||
### API Functions
|
||||
```typescript
|
||||
// src/features/users/api/users.ts
|
||||
import { apiClient } from '@/shared/lib/api-client';
|
||||
import { parseUsers, parseUser, type User, type UserCreate } from '../schemas/user.schema';
|
||||
|
||||
export async function fetchUsers(): Promise<User[]> {
|
||||
const response = await apiClient.get('/users');
|
||||
return parseUsers(response.data);
|
||||
}
|
||||
|
||||
export async function fetchUser(id: string): Promise<User> {
|
||||
const response = await apiClient.get(`/users/${id}`);
|
||||
return parseUser(response.data);
|
||||
}
|
||||
|
||||
export async function createUser(data: UserCreate): Promise<User> {
|
||||
const response = await apiClient.post('/users', data);
|
||||
return parseUser(response.data);
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
await apiClient.delete(`/users/${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Hooks
|
||||
```typescript
|
||||
// src/features/users/hooks/useUsers.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { fetchUsers, createUser, deleteUser } from '../api/users';
|
||||
import type { UserCreate } from '../schemas/user.schema';
|
||||
|
||||
export const userKeys = {
|
||||
all: ['users'] as const,
|
||||
lists: () => [...userKeys.all, 'list'] as const,
|
||||
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
|
||||
};
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.lists(),
|
||||
queryFn: fetchUsers,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UserCreate) => createUser(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## ESLint Configuration
|
||||
|
||||
```javascript
|
||||
// eslint.config.js
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'coverage', 'node_modules'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.strictTypeChecked],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports' },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
```json
|
||||
// tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Accessible Component with Radix
|
||||
```typescript
|
||||
// src/shared/components/ui/button.tsx
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input hover:bg-accent hover:text-accent-foreground',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Testing
|
||||
npm test # Run tests once
|
||||
npm run test:watch # Watch mode
|
||||
npm run test:coverage # With coverage
|
||||
npm run test:ui # Vitest UI
|
||||
|
||||
# Linting & Type Checking
|
||||
npm run lint # ESLint
|
||||
npm run lint:fix # Auto-fix
|
||||
npm run typecheck # tsc --noEmit
|
||||
|
||||
# Development
|
||||
npm run dev # Vite dev server
|
||||
npm run build # Production build
|
||||
npm run preview # Preview build
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
```typescript
|
||||
// BAD: any type
|
||||
const processData = (data: any) => data.value;
|
||||
|
||||
// GOOD: proper typing or unknown
|
||||
const processData = (data: unknown): string => {
|
||||
const parsed = schema.parse(data);
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
|
||||
// BAD: Mutation in state update
|
||||
const addItem = (item: Item) => {
|
||||
items.push(item); // Mutates array!
|
||||
setItems(items);
|
||||
};
|
||||
|
||||
// GOOD: Immutable update
|
||||
const addItem = (item: Item) => {
|
||||
setItems((prev) => [...prev, item]);
|
||||
};
|
||||
|
||||
|
||||
// BAD: useEffect for derived state
|
||||
const [fullName, setFullName] = useState('');
|
||||
useEffect(() => {
|
||||
setFullName(`${firstName} ${lastName}`);
|
||||
}, [firstName, lastName]);
|
||||
|
||||
// GOOD: Derive directly
|
||||
const fullName = `${firstName} ${lastName}`;
|
||||
|
||||
|
||||
// BAD: Testing implementation details
|
||||
it('should call setState', () => {
|
||||
const spy = vi.spyOn(React, 'useState');
|
||||
render(<Component />);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// GOOD: Test user-visible behavior
|
||||
it('should show error message for invalid input', async () => {
|
||||
render(<Form />);
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'invalid');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
expect(screen.getByText('Invalid email')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user