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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
# BookStore — Agent Instructions

## Purpose

Use this file for agent-only context: build and test commands, conventions, and project patterns. For human-facing details, see README and docs.

## Quick Reference

- **Stack**: .NET 10, C# 14, Marten, Wolverine, HybridCache, Aspire
- **Solution**: `BookStore.slnx` (new .NET 10 solution format)
- **Common commands**: `dotnet restore`, `aspire run`, `dotnet test`, `dotnet format`
- **Docs**: `docs/getting-started.md`, `docs/guides/`
- **Testing instructions**: `tests/AGENTS.md`

## Repository Map

- `src/BookStore.ApiService/`: Event-sourced API (Marten + Wolverine)
- `src/BookStore.Web/`: Blazor frontend
- `src/BookStore.AppHost/`: Aspire orchestration
Expand All @@ -21,6 +24,7 @@ Use this file for agent-only context: build and test commands, conventions, and
- `docs/`: architecture and guide material

## Major Patterns

- Modular monolith with event sourcing and CQRS
- Wolverine command/handler write model and async projections
- Marten projections for read models
Expand All @@ -29,6 +33,7 @@ Use this file for agent-only context: build and test commands, conventions, and
- Multi-tenancy with tenant-aware routing and storage

## Development Process (TDD)

1. Define verification (test, command, or browser check)
2. Write verification first
3. Implement
Expand All @@ -38,7 +43,8 @@ Use this file for agent-only context: build and test commands, conventions, and
**A feature is not complete until `dotnet format` has been executed successfully.**

## Code Rules (MUST follow)
```

```text
✅ Guid.CreateVersion7() ❌ Guid.NewGuid()
✅ DateTimeOffset.UtcNow ❌ DateTime.Now
✅ record BookAdded(...) ❌ record AddBook(...)
Expand All @@ -48,24 +54,28 @@ Use this file for agent-only context: build and test commands, conventions, and
```

## Conventions and Style

- Use `record` for DTOs/Commands/Events; events are past tense
- File-scoped namespaces only
- Follow `.editorconfig` and analyzer rules in `docs/guides/analyzer-rules.md`
- Central Package Management: versions live in `Directory.Packages.props`
- Shared build settings live in `Directory.Build.props`

## Common Mistakes

- Business logic in endpoints -> put it in aggregates/handlers
- Missing SSE notification -> add to `MartenCommitListener`
- Missing cache invalidation -> call `RemoveByTagAsync` after mutations

## Quick Troubleshooting

- Build failures: check BS1xxx-BS4xxx analyzer errors first
- SSE not working: run `/frontend__debug_sse`
- Cache issues: run `/cache__debug_cache`
- Environment issues: run `/ops__doctor_check`

## Documentation Index

- Setup: `docs/getting-started.md`
- Architecture: `docs/architecture.md`
- Event sourcing: `docs/guides/event-sourcing-guide.md`
Expand Down
35 changes: 18 additions & 17 deletions tests/BookStore.AppHost.Tests/AccountIsolationTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net;
using BookStore.AppHost.Tests.Helpers;
using BookStore.Client;
using BookStore.Shared.Models;
using JasperFx;
Expand Down Expand Up @@ -35,19 +36,19 @@ public static async Task ClassSetup()
opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined;
});

await TestHelpers.SeedTenantAsync(store, "acme");
await TestHelpers.SeedTenantAsync(store, "contoso");
await DatabaseHelpers.SeedTenantAsync(store, "acme");
await DatabaseHelpers.SeedTenantAsync(store, "contoso");
}

[Test]
public async Task User_RegisteredOnContoso_CannotLoginOnAcme()
{
// Arrange: Create a unique user email for this test
var userEmail = TestHelpers.GenerateFakeEmail();
var password = TestHelpers.GenerateFakePassword();
var userEmail = FakeDataGenerators.GenerateFakeEmail();
var password = FakeDataGenerators.GenerateFakePassword();

var contosoClient = RestService.For<IIdentityClient>(TestHelpers.GetUnauthenticatedClient("contoso"));
var acmeClient = RestService.For<IIdentityClient>(TestHelpers.GetUnauthenticatedClient("acme"));
var contosoClient = RestService.For<IIdentityClient>(HttpClientHelpers.GetUnauthenticatedClient("contoso"));
var acmeClient = RestService.For<IIdentityClient>(HttpClientHelpers.GetUnauthenticatedClient("acme"));

// Act 1: Register user on Contoso tenant
_ = await contosoClient.RegisterAsync(new RegisterRequest(userEmail, password));
Expand All @@ -64,10 +65,10 @@ public async Task User_RegisteredOnContoso_CannotLoginOnAcme()
public async Task User_RegisteredOnContoso_CanLoginOnContoso()
{
// Arrange: Create a unique user email for this test
var userEmail = TestHelpers.GenerateFakeEmail();
var password = TestHelpers.GenerateFakePassword();
var userEmail = FakeDataGenerators.GenerateFakeEmail();
var password = FakeDataGenerators.GenerateFakePassword();

var contosoClient = RestService.For<IIdentityClient>(TestHelpers.GetUnauthenticatedClient("contoso"));
var contosoClient = RestService.For<IIdentityClient>(HttpClientHelpers.GetUnauthenticatedClient("contoso"));

// Act 1: Register user on Contoso tenant
_ = await contosoClient.RegisterAsync(new RegisterRequest(userEmail, password));
Expand All @@ -84,11 +85,11 @@ public async Task User_RegisteredOnContoso_CanLoginOnContoso()
public async Task User_RegisteredOnAcme_CannotLoginOnContoso()
{
// Arrange: Create a unique user email for this test
var userEmail = TestHelpers.GenerateFakeEmail();
var password = TestHelpers.GenerateFakePassword();
var userEmail = FakeDataGenerators.GenerateFakeEmail();
var password = FakeDataGenerators.GenerateFakePassword();

var acmeClient = RestService.For<IIdentityClient>(TestHelpers.GetUnauthenticatedClient("acme"));
var contosoClient = RestService.For<IIdentityClient>(TestHelpers.GetUnauthenticatedClient("contoso"));
var acmeClient = RestService.For<IIdentityClient>(HttpClientHelpers.GetUnauthenticatedClient("acme"));
var contosoClient = RestService.For<IIdentityClient>(HttpClientHelpers.GetUnauthenticatedClient("contoso"));

// Act 1: Register user on Acme tenant
_ = await acmeClient.RegisterAsync(new RegisterRequest(userEmail, password));
Expand All @@ -105,11 +106,11 @@ public async Task User_RegisteredOnAcme_CannotLoginOnContoso()
public async Task User_RegisteredOnDefault_CannotLoginOnAcme()
{
// Arrange: Create a unique user email for this test
var userEmail = TestHelpers.GenerateFakeEmail();
var password = TestHelpers.GenerateFakePassword();
var userEmail = FakeDataGenerators.GenerateFakeEmail();
var password = FakeDataGenerators.GenerateFakePassword();

var defaultClient = RestService.For<IIdentityClient>(TestHelpers.GetUnauthenticatedClient());
var acmeClient = RestService.For<IIdentityClient>(TestHelpers.GetUnauthenticatedClient("acme"));
var defaultClient = RestService.For<IIdentityClient>(HttpClientHelpers.GetUnauthenticatedClient());
var acmeClient = RestService.For<IIdentityClient>(HttpClientHelpers.GetUnauthenticatedClient("acme"));

// Act 1: Register user on Default tenant (no X-Tenant-ID header)
_ = await defaultClient.RegisterAsync(new RegisterRequest(userEmail, password));
Expand Down
25 changes: 13 additions & 12 deletions tests/BookStore.AppHost.Tests/AdminTenantTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net;
using BookStore.ApiService.Models;
using BookStore.AppHost.Tests.Helpers;
using BookStore.Client;
using BookStore.Shared.Models;
using JasperFx.Events;
Expand All @@ -20,7 +21,7 @@ public async Task CreateTenant_WithInvalidPassword_ReturnsBadRequest()
}

var client =
RestService.For<ITenantsClient>(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!));
RestService.For<ITenantsClient>(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!));

// Arrange
var command = new CreateTenantCommand(
Expand All @@ -37,7 +38,7 @@ public async Task CreateTenant_WithInvalidPassword_ReturnsBadRequest()
var exception = await Assert.That(async () => await client.CreateTenantAsync(command)).Throws<ApiException>();
_ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);

var error = await exception.GetContentAsAsync<TestHelpers.ErrorResponse>();
var error = await exception.GetContentAsAsync<AuthenticationHelpers.ErrorResponse>();
_ = await Assert.That(error?.Error).IsEqualTo(ErrorCodes.Tenancy.InvalidAdminPassword);
}

Expand All @@ -50,7 +51,7 @@ public async Task CreateTenant_WithInvalidEmail_ReturnsBadRequest()
}

var client =
RestService.For<ITenantsClient>(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!));
RestService.For<ITenantsClient>(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!));

// Arrange
var command = new CreateTenantCommand(
Expand All @@ -60,14 +61,14 @@ public async Task CreateTenant_WithInvalidEmail_ReturnsBadRequest()
ThemePrimaryColor: "#ff0000",
IsEnabled: true,
AdminEmail: "invalid-email", // Invalid email
AdminPassword: TestHelpers.GenerateFakePassword()
AdminPassword: FakeDataGenerators.GenerateFakePassword()
);

// Act & Assert
var exception = await Assert.That(async () => await client.CreateTenantAsync(command)).Throws<ApiException>();
_ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);

var error = await exception.GetContentAsAsync<TestHelpers.ErrorResponse>();
var error = await exception.GetContentAsAsync<AuthenticationHelpers.ErrorResponse>();
_ = await Assert.That(error?.Error).IsEqualTo(ErrorCodes.Tenancy.InvalidAdminEmail);
}

Expand All @@ -80,7 +81,7 @@ public async Task CreateTenant_WithValidRequest_ReturnsCreated()
}

var client =
RestService.For<ITenantsClient>(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!));
RestService.For<ITenantsClient>(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!));

// Arrange
var tenantId = $"valid-tenant-{Guid.NewGuid():N}";
Expand All @@ -90,8 +91,8 @@ public async Task CreateTenant_WithValidRequest_ReturnsCreated()
Tagline: "Testing valid creation",
ThemePrimaryColor: "#00ff00",
IsEnabled: true,
AdminEmail: TestHelpers.GenerateFakeEmail(),
AdminPassword: TestHelpers.GenerateFakePassword() // Valid password
AdminEmail: FakeDataGenerators.GenerateFakeEmail(),
AdminPassword: FakeDataGenerators.GenerateFakePassword() // Valid password
);

// Act
Expand All @@ -107,24 +108,24 @@ public async Task CreateTenant_WithEmailVerification_CreatesUnconfirmedUser()
}

var client =
RestService.For<ITenantsClient>(TestHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!));
RestService.For<ITenantsClient>(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!));

// Arrange
var tenantId = $"verify-tenant-{Guid.NewGuid():N}";
var adminEmail = TestHelpers.GenerateFakeEmail();
var adminEmail = FakeDataGenerators.GenerateFakeEmail();
var command = new CreateTenantCommand(
Id: tenantId,
Name: "Verify Tenant",
Tagline: "Testing email verification",
ThemePrimaryColor: "#0000ff",
IsEnabled: true,
AdminEmail: adminEmail,
AdminPassword: TestHelpers.GenerateFakePassword()
AdminPassword: FakeDataGenerators.GenerateFakePassword()
);

// Act & Assert - Connect to SSE before creating, then wait for notification
// Creation triggers UserUpdated via UserProfile projection
var received = await TestHelpers.ExecuteAndWaitForEventAsync(
var received = await SseEventHelpers.ExecuteAndWaitForEventAsync(
Guid.Empty,
"UserUpdated",
async () => await client.CreateTenantAsync(command),
Expand Down
Loading
Loading